flurryx-code-memory 0.4.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.
- code_memory/__init__.py +1 -0
- code_memory/claims/__init__.py +32 -0
- code_memory/claims/extractor.py +325 -0
- code_memory/claims/indexer.py +258 -0
- code_memory/claims/resolver.py +186 -0
- code_memory/claims/store.py +424 -0
- code_memory/cli.py +1192 -0
- code_memory/config.py +268 -0
- code_memory/embed/__init__.py +224 -0
- code_memory/embed/cache.py +204 -0
- code_memory/embed/m3.py +174 -0
- code_memory/embed/ollama.py +92 -0
- code_memory/embed/tei.py +106 -0
- code_memory/episodic/__init__.py +3 -0
- code_memory/episodic/sqlite_store.py +278 -0
- code_memory/extractor/__init__.py +3 -0
- code_memory/extractor/csproj.py +166 -0
- code_memory/extractor/dll.py +385 -0
- code_memory/extractor/gitignore.py +162 -0
- code_memory/extractor/nuget.py +275 -0
- code_memory/extractor/sanity.py +124 -0
- code_memory/extractor/sln.py +108 -0
- code_memory/extractor/treesitter.py +1172 -0
- code_memory/graph/__init__.py +3 -0
- code_memory/graph/falkor_store.py +740 -0
- code_memory/mcp_server.py +1816 -0
- code_memory/metrics.py +260 -0
- code_memory/orchestrator/__init__.py +13 -0
- code_memory/orchestrator/git_delta.py +211 -0
- code_memory/orchestrator/ingest_state.py +71 -0
- code_memory/orchestrator/pipeline.py +1478 -0
- code_memory/orchestrator/reset.py +130 -0
- code_memory/orchestrator/resolver.py +825 -0
- code_memory/orchestrator/retrieve.py +505 -0
- code_memory/resilience.py +73 -0
- code_memory/sync/__init__.py +20 -0
- code_memory/sync/autostart/__init__.py +42 -0
- code_memory/sync/autostart/base.py +106 -0
- code_memory/sync/autostart/launchd.py +115 -0
- code_memory/sync/autostart/schtasks.py +155 -0
- code_memory/sync/autostart/systemd.py +113 -0
- code_memory/sync/hooks.py +164 -0
- code_memory/sync/safety.py +65 -0
- code_memory/sync/snapshot.py +461 -0
- code_memory/sync/store.py +399 -0
- code_memory/sync/sync.py +405 -0
- code_memory/sync/watcher.py +320 -0
- code_memory/vector/__init__.py +3 -0
- code_memory/vector/qdrant_store.py +302 -0
- flurryx_code_memory-0.4.0.dist-info/METADATA +26 -0
- flurryx_code_memory-0.4.0.dist-info/RECORD +53 -0
- flurryx_code_memory-0.4.0.dist-info/WHEEL +4 -0
- flurryx_code_memory-0.4.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Shared types + platform dispatch for autostart adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from . import Adapter
|
|
15
|
+
|
|
16
|
+
from ...config import detect_project_slug
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("codememory.autostart")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AutostartStatus:
|
|
23
|
+
installed: bool
|
|
24
|
+
running: bool
|
|
25
|
+
label: str
|
|
26
|
+
unit_path: str | None = None
|
|
27
|
+
note: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_adapter() -> Adapter:
|
|
31
|
+
"""Return the adapter for the current OS."""
|
|
32
|
+
system = platform.system()
|
|
33
|
+
if system == "Darwin":
|
|
34
|
+
from .launchd import LaunchdAdapter
|
|
35
|
+
|
|
36
|
+
return LaunchdAdapter()
|
|
37
|
+
if system == "Linux":
|
|
38
|
+
from .systemd import SystemdUserAdapter
|
|
39
|
+
|
|
40
|
+
return SystemdUserAdapter()
|
|
41
|
+
if system == "Windows":
|
|
42
|
+
from .schtasks import SchtasksAdapter
|
|
43
|
+
|
|
44
|
+
return SchtasksAdapter()
|
|
45
|
+
raise RuntimeError(f"unsupported OS: {system}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ensure_autostart(repo: Path, *, project: str | None = None) -> AutostartStatus:
|
|
49
|
+
"""Install + start the autostart service for ``repo`` if not already.
|
|
50
|
+
|
|
51
|
+
Idempotent. Safe to call on every MCP server boot.
|
|
52
|
+
"""
|
|
53
|
+
from ..safety import UnsafeWatchRootError, assert_safe_watch_root
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
repo = assert_safe_watch_root(repo)
|
|
57
|
+
except UnsafeWatchRootError as e:
|
|
58
|
+
return AutostartStatus(
|
|
59
|
+
installed=False,
|
|
60
|
+
running=False,
|
|
61
|
+
label="<unsafe-root>",
|
|
62
|
+
note=str(e),
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
adapter = get_adapter()
|
|
66
|
+
except RuntimeError as e:
|
|
67
|
+
return AutostartStatus(
|
|
68
|
+
installed=False,
|
|
69
|
+
running=False,
|
|
70
|
+
label="<unsupported>",
|
|
71
|
+
note=str(e),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
status = adapter.status(repo)
|
|
75
|
+
if status.installed and status.running:
|
|
76
|
+
return status
|
|
77
|
+
if not status.installed:
|
|
78
|
+
status = adapter.install(repo)
|
|
79
|
+
if status.installed and not status.running:
|
|
80
|
+
status = adapter.start(repo)
|
|
81
|
+
return status
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def watcher_command(repo: Path) -> list[str]:
|
|
85
|
+
"""Resolve the command line that launches the watcher.
|
|
86
|
+
|
|
87
|
+
Prefer the installed ``code-memory`` script; fall back to ``python -m``
|
|
88
|
+
invocation when the script isn't on PATH (development checkouts).
|
|
89
|
+
"""
|
|
90
|
+
exe = shutil.which("code-memory")
|
|
91
|
+
if exe:
|
|
92
|
+
return [exe, "watch", str(repo)]
|
|
93
|
+
return [sys.executable, "-m", "code_memory.cli", "watch", str(repo)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def repo_label(repo: Path) -> str:
|
|
97
|
+
"""Deterministic label / unit name suffix for a repo.
|
|
98
|
+
|
|
99
|
+
Uses the same slug logic as project detection so a repo's autostart
|
|
100
|
+
label matches its project slug — easy to spot in `launchctl list`,
|
|
101
|
+
`systemctl --user list-units`, or Task Scheduler.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
return detect_project_slug(repo)
|
|
105
|
+
except Exception: # noqa: BLE001
|
|
106
|
+
return repo.name.lower().replace(" ", "-")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""macOS launchd LaunchAgent adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import plistlib
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .base import AutostartStatus, repo_label, watcher_command
|
|
11
|
+
|
|
12
|
+
LABEL_PREFIX = "com.codememory.watch"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LaunchdAdapter:
|
|
16
|
+
def _label(self, repo: Path) -> str:
|
|
17
|
+
return f"{LABEL_PREFIX}.{repo_label(repo)}"
|
|
18
|
+
|
|
19
|
+
def _plist_path(self, repo: Path) -> Path:
|
|
20
|
+
agents = Path.home() / "Library" / "LaunchAgents"
|
|
21
|
+
return agents / f"{self._label(repo)}.plist"
|
|
22
|
+
|
|
23
|
+
def _logs_dir(self) -> Path:
|
|
24
|
+
return Path.home() / "Library" / "Logs" / "codememory"
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def install(self, repo: Path) -> AutostartStatus:
|
|
29
|
+
repo = Path(repo).resolve()
|
|
30
|
+
path = self._plist_path(repo)
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
logs = self._logs_dir()
|
|
33
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
label = self._label(repo)
|
|
35
|
+
|
|
36
|
+
plist = {
|
|
37
|
+
"Label": label,
|
|
38
|
+
"ProgramArguments": watcher_command(repo),
|
|
39
|
+
"WorkingDirectory": str(repo),
|
|
40
|
+
"RunAtLoad": True,
|
|
41
|
+
"KeepAlive": True,
|
|
42
|
+
"StandardOutPath": str(logs / f"{label}.log"),
|
|
43
|
+
"StandardErrorPath": str(logs / f"{label}.err"),
|
|
44
|
+
"ProcessType": "Background",
|
|
45
|
+
"EnvironmentVariables": {
|
|
46
|
+
"PATH": os.environ.get(
|
|
47
|
+
"PATH",
|
|
48
|
+
"/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin",
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
with path.open("wb") as fh:
|
|
53
|
+
plistlib.dump(plist, fh)
|
|
54
|
+
return AutostartStatus(
|
|
55
|
+
installed=True,
|
|
56
|
+
running=False,
|
|
57
|
+
label=label,
|
|
58
|
+
unit_path=str(path),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def uninstall(self, repo: Path) -> AutostartStatus:
|
|
62
|
+
label = self._label(repo)
|
|
63
|
+
path = self._plist_path(repo)
|
|
64
|
+
domain = f"gui/{os.getuid()}"
|
|
65
|
+
subprocess.run(
|
|
66
|
+
["launchctl", "bootout", domain, str(path)],
|
|
67
|
+
capture_output=True,
|
|
68
|
+
check=False,
|
|
69
|
+
)
|
|
70
|
+
path.unlink(missing_ok=True)
|
|
71
|
+
return AutostartStatus(
|
|
72
|
+
installed=False, running=False, label=label, unit_path=str(path)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def status(self, repo: Path) -> AutostartStatus:
|
|
76
|
+
label = self._label(repo)
|
|
77
|
+
path = self._plist_path(repo)
|
|
78
|
+
installed = path.is_file()
|
|
79
|
+
running = False
|
|
80
|
+
if installed:
|
|
81
|
+
out = subprocess.run(
|
|
82
|
+
["launchctl", "list", label],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
check=False,
|
|
86
|
+
)
|
|
87
|
+
running = out.returncode == 0
|
|
88
|
+
return AutostartStatus(
|
|
89
|
+
installed=installed,
|
|
90
|
+
running=running,
|
|
91
|
+
label=label,
|
|
92
|
+
unit_path=str(path),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def start(self, repo: Path) -> AutostartStatus:
|
|
96
|
+
label = self._label(repo)
|
|
97
|
+
path = self._plist_path(repo)
|
|
98
|
+
domain = f"gui/{os.getuid()}"
|
|
99
|
+
# bootstrap (may fail if already loaded — that's fine)
|
|
100
|
+
subprocess.run(
|
|
101
|
+
["launchctl", "bootstrap", domain, str(path)],
|
|
102
|
+
capture_output=True,
|
|
103
|
+
check=False,
|
|
104
|
+
)
|
|
105
|
+
subprocess.run(
|
|
106
|
+
["launchctl", "enable", f"{domain}/{label}"],
|
|
107
|
+
capture_output=True,
|
|
108
|
+
check=False,
|
|
109
|
+
)
|
|
110
|
+
subprocess.run(
|
|
111
|
+
["launchctl", "kickstart", "-k", f"{domain}/{label}"],
|
|
112
|
+
capture_output=True,
|
|
113
|
+
check=False,
|
|
114
|
+
)
|
|
115
|
+
return self.status(repo)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Windows Task Scheduler adapter.
|
|
2
|
+
|
|
3
|
+
Registers a per-user logon trigger via ``schtasks`` (no admin required).
|
|
4
|
+
Uses an XML definition so we can configure restart-on-failure semantics
|
|
5
|
+
that the simple ``/Create`` flags don't expose.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import getpass
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from xml.sax.saxutils import escape
|
|
15
|
+
|
|
16
|
+
from .base import AutostartStatus, repo_label, watcher_command
|
|
17
|
+
|
|
18
|
+
TASK_FOLDER = "CodeMemory\\Watch"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SchtasksAdapter:
|
|
22
|
+
def _task_name(self, repo: Path) -> str:
|
|
23
|
+
return f"{TASK_FOLDER}\\{repo_label(repo)}"
|
|
24
|
+
|
|
25
|
+
def install(self, repo: Path) -> AutostartStatus:
|
|
26
|
+
repo = Path(repo).resolve()
|
|
27
|
+
argv = watcher_command(repo)
|
|
28
|
+
exe = argv[0]
|
|
29
|
+
args = " ".join(_quote_win(a) for a in argv[1:])
|
|
30
|
+
user = getpass.getuser()
|
|
31
|
+
xml = _TASK_XML_TEMPLATE.format(
|
|
32
|
+
description=escape(f"code-memory watcher for {repo}"),
|
|
33
|
+
user=escape(user),
|
|
34
|
+
exe=escape(exe),
|
|
35
|
+
args=escape(args),
|
|
36
|
+
working_dir=escape(str(repo)),
|
|
37
|
+
)
|
|
38
|
+
with tempfile.NamedTemporaryFile(
|
|
39
|
+
"w", suffix=".xml", delete=False, encoding="utf-16"
|
|
40
|
+
) as fh:
|
|
41
|
+
fh.write(xml)
|
|
42
|
+
xml_path = fh.name
|
|
43
|
+
try:
|
|
44
|
+
res = subprocess.run(
|
|
45
|
+
[
|
|
46
|
+
"schtasks",
|
|
47
|
+
"/Create",
|
|
48
|
+
"/TN",
|
|
49
|
+
self._task_name(repo),
|
|
50
|
+
"/XML",
|
|
51
|
+
xml_path,
|
|
52
|
+
"/F",
|
|
53
|
+
],
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
check=False,
|
|
57
|
+
)
|
|
58
|
+
ok = res.returncode == 0
|
|
59
|
+
finally:
|
|
60
|
+
Path(xml_path).unlink(missing_ok=True)
|
|
61
|
+
return AutostartStatus(
|
|
62
|
+
installed=ok,
|
|
63
|
+
running=False,
|
|
64
|
+
label=self._task_name(repo),
|
|
65
|
+
note=None if ok else res.stderr.strip(),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def uninstall(self, repo: Path) -> AutostartStatus:
|
|
69
|
+
name = self._task_name(repo)
|
|
70
|
+
subprocess.run(
|
|
71
|
+
["schtasks", "/Delete", "/TN", name, "/F"],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
check=False,
|
|
74
|
+
)
|
|
75
|
+
return AutostartStatus(installed=False, running=False, label=name)
|
|
76
|
+
|
|
77
|
+
def status(self, repo: Path) -> AutostartStatus:
|
|
78
|
+
name = self._task_name(repo)
|
|
79
|
+
out = subprocess.run(
|
|
80
|
+
["schtasks", "/Query", "/TN", name, "/FO", "LIST"],
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
check=False,
|
|
84
|
+
)
|
|
85
|
+
if out.returncode != 0:
|
|
86
|
+
return AutostartStatus(installed=False, running=False, label=name)
|
|
87
|
+
running = "Status:" in out.stdout and "Running" in out.stdout
|
|
88
|
+
return AutostartStatus(installed=True, running=running, label=name)
|
|
89
|
+
|
|
90
|
+
def start(self, repo: Path) -> AutostartStatus:
|
|
91
|
+
name = self._task_name(repo)
|
|
92
|
+
subprocess.run(
|
|
93
|
+
["schtasks", "/Run", "/TN", name],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
check=False,
|
|
96
|
+
)
|
|
97
|
+
return self.status(repo)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _quote_win(arg: str) -> str:
|
|
101
|
+
if not arg or any(c in arg for c in ' \t"'):
|
|
102
|
+
return '"' + arg.replace('"', '\\"') + '"'
|
|
103
|
+
return arg
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_TASK_XML_TEMPLATE = """<?xml version="1.0" encoding="UTF-16"?>
|
|
107
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
108
|
+
<RegistrationInfo>
|
|
109
|
+
<Description>{description}</Description>
|
|
110
|
+
</RegistrationInfo>
|
|
111
|
+
<Triggers>
|
|
112
|
+
<LogonTrigger>
|
|
113
|
+
<Enabled>true</Enabled>
|
|
114
|
+
<UserId>{user}</UserId>
|
|
115
|
+
</LogonTrigger>
|
|
116
|
+
</Triggers>
|
|
117
|
+
<Principals>
|
|
118
|
+
<Principal id="Author">
|
|
119
|
+
<UserId>{user}</UserId>
|
|
120
|
+
<LogonType>InteractiveToken</LogonType>
|
|
121
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
122
|
+
</Principal>
|
|
123
|
+
</Principals>
|
|
124
|
+
<Settings>
|
|
125
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
126
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
127
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
128
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
129
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
130
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
131
|
+
<IdleSettings>
|
|
132
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
133
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
134
|
+
</IdleSettings>
|
|
135
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
136
|
+
<Enabled>true</Enabled>
|
|
137
|
+
<Hidden>false</Hidden>
|
|
138
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
139
|
+
<WakeToRun>false</WakeToRun>
|
|
140
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
141
|
+
<Priority>7</Priority>
|
|
142
|
+
<RestartOnFailure>
|
|
143
|
+
<Interval>PT1M</Interval>
|
|
144
|
+
<Count>999</Count>
|
|
145
|
+
</RestartOnFailure>
|
|
146
|
+
</Settings>
|
|
147
|
+
<Actions Context="Author">
|
|
148
|
+
<Exec>
|
|
149
|
+
<Command>{exe}</Command>
|
|
150
|
+
<Arguments>{args}</Arguments>
|
|
151
|
+
<WorkingDirectory>{working_dir}</WorkingDirectory>
|
|
152
|
+
</Exec>
|
|
153
|
+
</Actions>
|
|
154
|
+
</Task>
|
|
155
|
+
"""
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Linux systemd --user adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .base import AutostartStatus, repo_label, watcher_command
|
|
11
|
+
|
|
12
|
+
UNIT_PREFIX = "codememory-watch"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SystemdUserAdapter:
|
|
16
|
+
def _unit(self, repo: Path) -> str:
|
|
17
|
+
return f"{UNIT_PREFIX}-{repo_label(repo)}.service"
|
|
18
|
+
|
|
19
|
+
def _unit_path(self, repo: Path) -> Path:
|
|
20
|
+
base = (
|
|
21
|
+
Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
22
|
+
/ "systemd"
|
|
23
|
+
/ "user"
|
|
24
|
+
)
|
|
25
|
+
return base / self._unit(repo)
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def install(self, repo: Path) -> AutostartStatus:
|
|
30
|
+
repo = Path(repo).resolve()
|
|
31
|
+
unit_path = self._unit_path(repo)
|
|
32
|
+
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
exec_start = " ".join(shlex.quote(arg) for arg in watcher_command(repo))
|
|
34
|
+
unit = f"""[Unit]
|
|
35
|
+
Description=code-memory watcher ({repo.name})
|
|
36
|
+
After=default.target
|
|
37
|
+
|
|
38
|
+
[Service]
|
|
39
|
+
Type=simple
|
|
40
|
+
ExecStart={exec_start}
|
|
41
|
+
WorkingDirectory={repo}
|
|
42
|
+
Restart=on-failure
|
|
43
|
+
RestartSec=5
|
|
44
|
+
Environment=PATH={os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin')}
|
|
45
|
+
|
|
46
|
+
[Install]
|
|
47
|
+
WantedBy=default.target
|
|
48
|
+
"""
|
|
49
|
+
unit_path.write_text(unit)
|
|
50
|
+
subprocess.run(
|
|
51
|
+
["systemctl", "--user", "daemon-reload"],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
check=False,
|
|
54
|
+
)
|
|
55
|
+
return AutostartStatus(
|
|
56
|
+
installed=True,
|
|
57
|
+
running=False,
|
|
58
|
+
label=self._unit(repo),
|
|
59
|
+
unit_path=str(unit_path),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def uninstall(self, repo: Path) -> AutostartStatus:
|
|
63
|
+
unit = self._unit(repo)
|
|
64
|
+
path = self._unit_path(repo)
|
|
65
|
+
subprocess.run(
|
|
66
|
+
["systemctl", "--user", "disable", "--now", unit],
|
|
67
|
+
capture_output=True,
|
|
68
|
+
check=False,
|
|
69
|
+
)
|
|
70
|
+
path.unlink(missing_ok=True)
|
|
71
|
+
subprocess.run(
|
|
72
|
+
["systemctl", "--user", "daemon-reload"],
|
|
73
|
+
capture_output=True,
|
|
74
|
+
check=False,
|
|
75
|
+
)
|
|
76
|
+
return AutostartStatus(
|
|
77
|
+
installed=False, running=False, label=unit, unit_path=str(path)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def status(self, repo: Path) -> AutostartStatus:
|
|
81
|
+
unit = self._unit(repo)
|
|
82
|
+
path = self._unit_path(repo)
|
|
83
|
+
installed = path.is_file()
|
|
84
|
+
running = False
|
|
85
|
+
if installed:
|
|
86
|
+
out = subprocess.run(
|
|
87
|
+
["systemctl", "--user", "is-active", unit],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
check=False,
|
|
91
|
+
)
|
|
92
|
+
running = out.stdout.strip() == "active"
|
|
93
|
+
return AutostartStatus(
|
|
94
|
+
installed=installed,
|
|
95
|
+
running=running,
|
|
96
|
+
label=unit,
|
|
97
|
+
unit_path=str(path),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def start(self, repo: Path) -> AutostartStatus:
|
|
101
|
+
unit = self._unit(repo)
|
|
102
|
+
subprocess.run(
|
|
103
|
+
["systemctl", "--user", "enable", "--now", unit],
|
|
104
|
+
capture_output=True,
|
|
105
|
+
check=False,
|
|
106
|
+
)
|
|
107
|
+
# best-effort linger so the service can run without active session
|
|
108
|
+
subprocess.run(
|
|
109
|
+
["loginctl", "enable-linger", os.getenv("USER", "")],
|
|
110
|
+
capture_output=True,
|
|
111
|
+
check=False,
|
|
112
|
+
)
|
|
113
|
+
return self.status(repo)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Git hooks installer: post-checkout / post-merge / post-rewrite / post-commit.
|
|
2
|
+
|
|
3
|
+
Each hook fires ``code-memory sync`` in the background. Hooks are
|
|
4
|
+
idempotent — the installer detects an existing code-memory block and
|
|
5
|
+
overwrites it without disturbing the rest of the file. Plain ``rm`` of
|
|
6
|
+
the marker block removes our integration cleanly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
HOOKS = ("post-checkout", "post-merge", "post-rewrite", "post-commit", "post-applypatch")
|
|
17
|
+
|
|
18
|
+
MARKER_START = "# >>> code-memory sync >>>"
|
|
19
|
+
MARKER_END = "# <<< code-memory sync <<<"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class HookInstallResult:
|
|
24
|
+
installed: list[str]
|
|
25
|
+
skipped: list[str]
|
|
26
|
+
hooks_dir: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def install_hooks(repo: Path, *, trigger_cmd: str | None = None) -> HookInstallResult:
|
|
30
|
+
"""Install code-memory hooks into ``repo``.
|
|
31
|
+
|
|
32
|
+
Returns lists of hook names that were (re)written vs skipped.
|
|
33
|
+
"""
|
|
34
|
+
repo = Path(repo).resolve()
|
|
35
|
+
hooks_dir = _hooks_dir(repo)
|
|
36
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
trigger = trigger_cmd or _default_trigger()
|
|
39
|
+
installed: list[str] = []
|
|
40
|
+
skipped: list[str] = []
|
|
41
|
+
for hook in HOOKS:
|
|
42
|
+
path = hooks_dir / hook
|
|
43
|
+
block = _block_for(hook, trigger)
|
|
44
|
+
if path.exists():
|
|
45
|
+
current = path.read_text()
|
|
46
|
+
if MARKER_START in current and MARKER_END in current:
|
|
47
|
+
new = _replace_block(current, block)
|
|
48
|
+
if new == current:
|
|
49
|
+
skipped.append(hook)
|
|
50
|
+
continue
|
|
51
|
+
else:
|
|
52
|
+
new = current.rstrip() + "\n\n" + block + "\n"
|
|
53
|
+
else:
|
|
54
|
+
new = "#!/usr/bin/env bash\nset -euo pipefail\n\n" + block + "\n"
|
|
55
|
+
path.write_text(new)
|
|
56
|
+
_chmod_exec(path)
|
|
57
|
+
installed.append(hook)
|
|
58
|
+
return HookInstallResult(
|
|
59
|
+
installed=installed,
|
|
60
|
+
skipped=skipped,
|
|
61
|
+
hooks_dir=str(hooks_dir),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def uninstall_hooks(repo: Path) -> HookInstallResult:
|
|
66
|
+
repo = Path(repo).resolve()
|
|
67
|
+
hooks_dir = _hooks_dir(repo)
|
|
68
|
+
removed: list[str] = []
|
|
69
|
+
skipped: list[str] = []
|
|
70
|
+
for hook in HOOKS:
|
|
71
|
+
path = hooks_dir / hook
|
|
72
|
+
if not path.exists():
|
|
73
|
+
skipped.append(hook)
|
|
74
|
+
continue
|
|
75
|
+
content = path.read_text()
|
|
76
|
+
if MARKER_START not in content:
|
|
77
|
+
skipped.append(hook)
|
|
78
|
+
continue
|
|
79
|
+
stripped = _strip_block(content)
|
|
80
|
+
if stripped.strip() in ("", "#!/usr/bin/env bash", "#!/usr/bin/env bash\nset -euo pipefail"):
|
|
81
|
+
path.unlink()
|
|
82
|
+
else:
|
|
83
|
+
path.write_text(stripped)
|
|
84
|
+
_chmod_exec(path)
|
|
85
|
+
removed.append(hook)
|
|
86
|
+
return HookInstallResult(
|
|
87
|
+
installed=removed,
|
|
88
|
+
skipped=skipped,
|
|
89
|
+
hooks_dir=str(hooks_dir),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def hook_status(repo: Path) -> dict[str, bool]:
|
|
94
|
+
hooks_dir = _hooks_dir(Path(repo).resolve())
|
|
95
|
+
out: dict[str, bool] = {}
|
|
96
|
+
for hook in HOOKS:
|
|
97
|
+
path = hooks_dir / hook
|
|
98
|
+
out[hook] = path.is_file() and MARKER_START in path.read_text(errors="ignore")
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _hooks_dir(repo: Path) -> Path:
|
|
106
|
+
"""Return the hooks directory honouring ``core.hooksPath`` when set."""
|
|
107
|
+
import subprocess
|
|
108
|
+
|
|
109
|
+
out = subprocess.run(
|
|
110
|
+
["git", "-C", str(repo), "config", "--get", "core.hooksPath"],
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
check=False,
|
|
114
|
+
)
|
|
115
|
+
custom = out.stdout.strip()
|
|
116
|
+
if custom:
|
|
117
|
+
p = Path(custom)
|
|
118
|
+
if not p.is_absolute():
|
|
119
|
+
p = repo / p
|
|
120
|
+
return p
|
|
121
|
+
return repo / ".git" / "hooks"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _default_trigger() -> str:
|
|
125
|
+
"""The command embedded in each hook.
|
|
126
|
+
|
|
127
|
+
Background + disown so git never blocks on the sync. Stderr -> log
|
|
128
|
+
file so failures are observable.
|
|
129
|
+
"""
|
|
130
|
+
log_path = "$HOME/.cache/codememory/hook.log"
|
|
131
|
+
return (
|
|
132
|
+
f"mkdir -p \"$(dirname {log_path})\" && "
|
|
133
|
+
f"( code-memory sync . --trigger \"$HOOK\" "
|
|
134
|
+
f">> {log_path} 2>&1 & ) "
|
|
135
|
+
"; disown 2>/dev/null || true"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _block_for(hook: str, trigger: str) -> str:
|
|
140
|
+
return (
|
|
141
|
+
f"{MARKER_START}\n"
|
|
142
|
+
f"HOOK={hook}\n"
|
|
143
|
+
f"command -v code-memory >/dev/null 2>&1 && {{ {trigger}; }}\n"
|
|
144
|
+
f"{MARKER_END}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _replace_block(content: str, block: str) -> str:
|
|
149
|
+
start = content.index(MARKER_START)
|
|
150
|
+
end = content.index(MARKER_END, start) + len(MARKER_END)
|
|
151
|
+
return content[:start] + block + content[end:]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _strip_block(content: str) -> str:
|
|
155
|
+
start = content.index(MARKER_START)
|
|
156
|
+
end = content.index(MARKER_END, start) + len(MARKER_END)
|
|
157
|
+
return (content[:start].rstrip() + "\n" + content[end:].lstrip()).strip() + "\n"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _chmod_exec(path: Path) -> None:
|
|
161
|
+
if os.name == "nt":
|
|
162
|
+
return # Git for Windows uses bash; chmod n/a
|
|
163
|
+
mode = path.stat().st_mode
|
|
164
|
+
path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|