cursorfleet 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.
- cursorfleet/__init__.py +9 -0
- cursorfleet/_templates/cursor/agents/architect.md +18 -0
- cursorfleet/_templates/cursor/agents/implementer.md +20 -0
- cursorfleet/_templates/cursor/agents/patcher.md +17 -0
- cursorfleet/_templates/cursor/agents/qa.md +21 -0
- cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
- cursorfleet/_templates/cursor/agents/scout.md +17 -0
- cursorfleet/_templates/cursor/agents/tester.md +20 -0
- cursorfleet/_templates/cursor/agents-md/root.md +7 -0
- cursorfleet/_templates/cursor/agents-md/work.md +7 -0
- cursorfleet/_templates/cursor/config/config.toml +47 -0
- cursorfleet/_templates/cursor/hooks/entry.json +4 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
- cursorfleet/adapters/__init__.py +0 -0
- cursorfleet/adapters/cursor/__init__.py +0 -0
- cursorfleet/adapters/cursor/blocks.py +104 -0
- cursorfleet/adapters/cursor/diagnostics.py +547 -0
- cursorfleet/adapters/cursor/fsutil.py +156 -0
- cursorfleet/adapters/cursor/hook_main.py +428 -0
- cursorfleet/adapters/cursor/hook_normalize.py +527 -0
- cursorfleet/adapters/cursor/hook_policy.py +76 -0
- cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
- cursorfleet/adapters/cursor/hooksjson.py +305 -0
- cursorfleet/adapters/cursor/installer.py +626 -0
- cursorfleet/adapters/cursor/kit.py +210 -0
- cursorfleet/adapters/cursor/kit_probe.py +54 -0
- cursorfleet/adapters/cursor/lock.py +145 -0
- cursorfleet/adapters/cursor/templates.py +60 -0
- cursorfleet/adapters/cursor/validation.py +365 -0
- cursorfleet/adapters/cursor/workspace.py +80 -0
- cursorfleet/cli/__init__.py +0 -0
- cursorfleet/cli/commands/__init__.py +1 -0
- cursorfleet/cli/commands/_common.py +82 -0
- cursorfleet/cli/commands/_resolution.py +56 -0
- cursorfleet/cli/commands/doctor.py +81 -0
- cursorfleet/cli/commands/events.py +197 -0
- cursorfleet/cli/commands/index.py +55 -0
- cursorfleet/cli/commands/init.py +71 -0
- cursorfleet/cli/commands/migrate.py +43 -0
- cursorfleet/cli/commands/replay.py +76 -0
- cursorfleet/cli/commands/run.py +97 -0
- cursorfleet/cli/commands/status.py +210 -0
- cursorfleet/cli/commands/tui.py +47 -0
- cursorfleet/cli/commands/uninstall.py +73 -0
- cursorfleet/cli/commands/validate.py +69 -0
- cursorfleet/cli/main.py +81 -0
- cursorfleet/config/__init__.py +44 -0
- cursorfleet/config/io.py +27 -0
- cursorfleet/config/models.py +154 -0
- cursorfleet/config/roster.py +245 -0
- cursorfleet/events/__init__.py +39 -0
- cursorfleet/events/forbidden.py +45 -0
- cursorfleet/events/ids.py +109 -0
- cursorfleet/events/kinds.py +132 -0
- cursorfleet/events/models.py +213 -0
- cursorfleet/events/pathcheck.py +45 -0
- cursorfleet/git/__init__.py +0 -0
- cursorfleet/git/collector.py +226 -0
- cursorfleet/git/runner.py +135 -0
- cursorfleet/git/worktrees.py +71 -0
- cursorfleet/policy/__init__.py +0 -0
- cursorfleet/py.typed +0 -0
- cursorfleet/safeexe.py +70 -0
- cursorfleet/schema_gen.py +73 -0
- cursorfleet/state/__init__.py +0 -0
- cursorfleet/state/artifact_scan.py +309 -0
- cursorfleet/state/context.py +38 -0
- cursorfleet/state/event_store.py +227 -0
- cursorfleet/state/indexer.py +443 -0
- cursorfleet/state/lock.py +74 -0
- cursorfleet/state/models.py +268 -0
- cursorfleet/state/reducer.py +588 -0
- cursorfleet/state/retention.py +218 -0
- cursorfleet/state/run_builder.py +136 -0
- cursorfleet/state/run_model.py +43 -0
- cursorfleet/state/run_store.py +95 -0
- cursorfleet/state/runtime.py +645 -0
- cursorfleet/state/spool.py +186 -0
- cursorfleet/state/spool_read.py +242 -0
- cursorfleet/state/status_doc.py +293 -0
- cursorfleet/tui/__init__.py +0 -0
- cursorfleet/tui/app.py +581 -0
- cursorfleet/tui/data.py +545 -0
- cursorfleet/tui/filters.py +82 -0
- cursorfleet/tui/gates.py +255 -0
- cursorfleet/tui/help.py +101 -0
- cursorfleet/tui/layout.py +26 -0
- cursorfleet/tui/theme.py +176 -0
- cursorfleet/tui/views.py +1077 -0
- cursorfleet/tui/watch.py +33 -0
- cursorfleet/workflow/__init__.py +0 -0
- cursorfleet/workflow/artifacts.py +148 -0
- cursorfleet/workflow/frontmatter.py +232 -0
- cursorfleet-0.1.0.dist-info/METADATA +161 -0
- cursorfleet-0.1.0.dist-info/RECORD +101 -0
- cursorfleet-0.1.0.dist-info/WHEEL +4 -0
- cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
- cursorfleet-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""Runtime directory resolution and private-file helpers. Stdlib only (hot-path safe).
|
|
2
|
+
|
|
3
|
+
Layout (ADR 0002)::
|
|
4
|
+
|
|
5
|
+
<git-common-dir>/cursorfleet/
|
|
6
|
+
LAYOUT, hmac.key, spool/<session>/<writer>.jsonl, index/state.sqlite,
|
|
7
|
+
locks/indexer.lock, quarantine/
|
|
8
|
+
|
|
9
|
+
PROVISIONAL (ADR 0001 Q4): that the common dir resolves identically from every
|
|
10
|
+
Cursor surface (IDE, CLI, Agents Window worktrees) is unverified.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import stat
|
|
17
|
+
|
|
18
|
+
LAYOUT_VERSION = "1"
|
|
19
|
+
RUNTIME_DIRNAME = "cursorfleet"
|
|
20
|
+
_MAX_WALK = 64
|
|
21
|
+
_MAX_SMALL_READ = 4096
|
|
22
|
+
|
|
23
|
+
DIR_MODE = 0o700
|
|
24
|
+
FILE_MODE = 0o600
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GitLocation:
|
|
28
|
+
"""Where a path sits in git, found by reading ``.git`` files (no subprocess).
|
|
29
|
+
|
|
30
|
+
A plain class on purpose: ``dataclasses`` costs ~12 ms of import time on the hook hot path.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
__slots__ = ("common_dir", "git_dir", "top_level")
|
|
34
|
+
|
|
35
|
+
def __init__(self, top_level: str, git_dir: str, common_dir: str) -> None:
|
|
36
|
+
self.top_level = top_level # realpath of the directory containing the ``.git`` entry
|
|
37
|
+
self.git_dir = git_dir # realpath of this worktree's git dir (== common for main)
|
|
38
|
+
self.common_dir = common_dir # realpath of the shared git dir
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str:
|
|
41
|
+
return f"GitLocation({self.top_level!r}, {self.git_dir!r}, {self.common_dir!r})"
|
|
42
|
+
|
|
43
|
+
def __eq__(self, other: object) -> bool:
|
|
44
|
+
return isinstance(other, GitLocation) and (
|
|
45
|
+
(self.top_level, self.git_dir, self.common_dir)
|
|
46
|
+
== (other.top_level, other.git_dir, other.common_dir)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def __hash__(self) -> int:
|
|
50
|
+
return hash((self.top_level, self.git_dir, self.common_dir))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RuntimePaths:
|
|
54
|
+
"""Absolute paths (``str``: no pathlib import on the hot path) in the runtime dir."""
|
|
55
|
+
|
|
56
|
+
__slots__ = ("root",)
|
|
57
|
+
|
|
58
|
+
def __init__(self, root: str) -> None:
|
|
59
|
+
self.root = root
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def spool(self) -> str:
|
|
63
|
+
return os.path.join(self.root, "spool")
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def index_dir(self) -> str:
|
|
67
|
+
return os.path.join(self.root, "index")
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def db(self) -> str:
|
|
71
|
+
return os.path.join(self.index_dir, "state.sqlite")
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def locks(self) -> str:
|
|
75
|
+
return os.path.join(self.root, "locks")
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def indexer_lock(self) -> str:
|
|
79
|
+
return os.path.join(self.locks, "indexer.lock")
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def quarantine(self) -> str:
|
|
83
|
+
return os.path.join(self.root, "quarantine")
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def hmac_key(self) -> str:
|
|
87
|
+
return os.path.join(self.root, "hmac.key")
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def layout(self) -> str:
|
|
91
|
+
return os.path.join(self.root, "LAYOUT")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def runtime_paths(common_dir: str | os.PathLike[str]) -> RuntimePaths:
|
|
95
|
+
return RuntimePaths(os.path.join(os.fspath(common_dir), RUNTIME_DIRNAME))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def safe_realpath(path: str) -> str:
|
|
99
|
+
"""``os.path.realpath`` that still works when ``getcwd`` is unusable.
|
|
100
|
+
|
|
101
|
+
Windows ``ntpath.realpath`` can call ``getcwd`` even for a drive-absolute path.
|
|
102
|
+
If that raises and ``path`` is already absolute, fall back to ``normpath``
|
|
103
|
+
(no further symlink resolution). Never used for relative paths: those stay
|
|
104
|
+
rejected so a broken cwd cannot silently mean "somewhere else".
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
return os.path.realpath(path)
|
|
108
|
+
except OSError:
|
|
109
|
+
if os.path.isabs(path):
|
|
110
|
+
return os.path.normpath(path)
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _read_small(path: str) -> str:
|
|
115
|
+
with open(path, "rb") as handle:
|
|
116
|
+
return handle.read(_MAX_SMALL_READ).decode("utf-8", "replace")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _first_line(text: str) -> str:
|
|
120
|
+
lines = text.strip().splitlines()
|
|
121
|
+
return lines[0].strip() if lines else ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_gitfile(entry: str) -> tuple[str, str] | None:
|
|
125
|
+
"""Return ``(git_dir, common_dir)`` for a ``.git`` file (linked worktree/submodule)."""
|
|
126
|
+
line = _first_line(_read_small(entry))
|
|
127
|
+
if not line.startswith("gitdir:"):
|
|
128
|
+
return None
|
|
129
|
+
gitdir = line[len("gitdir:") :].strip()
|
|
130
|
+
if not gitdir:
|
|
131
|
+
return None
|
|
132
|
+
if not os.path.isabs(gitdir):
|
|
133
|
+
gitdir = os.path.join(os.path.dirname(entry), gitdir)
|
|
134
|
+
gitdir = safe_realpath(gitdir)
|
|
135
|
+
common = gitdir
|
|
136
|
+
commondir_file = os.path.join(gitdir, "commondir")
|
|
137
|
+
if os.path.isfile(commondir_file):
|
|
138
|
+
value = _first_line(_read_small(commondir_file))
|
|
139
|
+
if value:
|
|
140
|
+
common = safe_realpath(value if os.path.isabs(value) else os.path.join(gitdir, value))
|
|
141
|
+
return gitdir, common
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def find_git_location(start: str) -> GitLocation | None: # noqa: PLR0911
|
|
145
|
+
"""Walk up from ``start`` looking for ``.git``; resolve it without spawning git.
|
|
146
|
+
|
|
147
|
+
Equivalent to ``git rev-parse --show-toplevel / --git-dir / --git-common-dir`` for
|
|
148
|
+
ordinary checkouts, linked worktrees and submodules (contract-tested against git).
|
|
149
|
+
Returns ``None`` outside a git repo. Never raises.
|
|
150
|
+
"""
|
|
151
|
+
if not start or not os.path.isabs(start):
|
|
152
|
+
return None # relative/empty starts would silently mean "the cwd"
|
|
153
|
+
try:
|
|
154
|
+
current = safe_realpath(start)
|
|
155
|
+
for _ in range(_MAX_WALK):
|
|
156
|
+
entry = os.path.join(current, ".git")
|
|
157
|
+
if os.path.lexists(entry):
|
|
158
|
+
if os.path.isdir(entry):
|
|
159
|
+
real = safe_realpath(entry)
|
|
160
|
+
return GitLocation(current, real, real)
|
|
161
|
+
resolved = _resolve_gitfile(entry)
|
|
162
|
+
if resolved is None:
|
|
163
|
+
return None
|
|
164
|
+
return GitLocation(current, resolved[0], resolved[1])
|
|
165
|
+
parent = os.path.dirname(current)
|
|
166
|
+
if parent == current:
|
|
167
|
+
return None
|
|
168
|
+
current = parent
|
|
169
|
+
except (OSError, ValueError):
|
|
170
|
+
return None
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
|
175
|
+
INSTALL_MARKER_DIR = ".cursorfleet"
|
|
176
|
+
INSTALL_MARKER_NAME = "config.toml"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_link_like(info: os.stat_result) -> bool:
|
|
180
|
+
"""True for a symlink or a Windows reparse point (junction), matching ``untrusted_reason``."""
|
|
181
|
+
return bool(
|
|
182
|
+
stat.S_ISLNK(info.st_mode)
|
|
183
|
+
or getattr(info, "st_file_attributes", 0) & _FILE_ATTRIBUTE_REPARSE_POINT
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def has_install_marker(top_level: str) -> bool:
|
|
188
|
+
"""True when ``<top_level>/.cursorfleet/config.toml`` is a regular file (ADR 0002).
|
|
189
|
+
|
|
190
|
+
The marker must exist before any runtime directory or event file is created.
|
|
191
|
+
A symlink or junction on ``.cursorfleet`` or ``config.toml`` (including a
|
|
192
|
+
link-to-elsewhere) is refused, consistent with runtime path safety.
|
|
193
|
+
Never creates files. Never raises.
|
|
194
|
+
"""
|
|
195
|
+
if not top_level:
|
|
196
|
+
return False
|
|
197
|
+
try:
|
|
198
|
+
directory = os.path.join(top_level, INSTALL_MARKER_DIR)
|
|
199
|
+
marker = os.path.join(directory, INSTALL_MARKER_NAME)
|
|
200
|
+
dir_info = os.lstat(directory)
|
|
201
|
+
file_info = os.lstat(marker)
|
|
202
|
+
real = safe_realpath(marker)
|
|
203
|
+
real_info = os.lstat(real)
|
|
204
|
+
root_real = safe_realpath(top_level)
|
|
205
|
+
prefix = root_real if root_real.endswith(os.sep) else root_real + os.sep
|
|
206
|
+
return (
|
|
207
|
+
not _is_link_like(dir_info)
|
|
208
|
+
and stat.S_ISDIR(dir_info.st_mode)
|
|
209
|
+
and not _is_link_like(file_info)
|
|
210
|
+
and stat.S_ISREG(file_info.st_mode)
|
|
211
|
+
and not _is_link_like(real_info)
|
|
212
|
+
and stat.S_ISREG(real_info.st_mode)
|
|
213
|
+
and os.path.normcase(real).startswith(os.path.normcase(prefix))
|
|
214
|
+
)
|
|
215
|
+
except OSError:
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def resolve_inherited_location(start: str) -> GitLocation | None:
|
|
220
|
+
"""Nearest Git root from ``start`` if it already has the install marker.
|
|
221
|
+
|
|
222
|
+
Stops at the first ``.git`` directory or gitfile. Does not walk past an inner
|
|
223
|
+
repository boundary when that root has no marker. Creates nothing. Never raises.
|
|
224
|
+
"""
|
|
225
|
+
location = find_git_location(start)
|
|
226
|
+
if location is None or not has_install_marker(location.top_level):
|
|
227
|
+
return None
|
|
228
|
+
return location
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
BOUNDARY_REPOSITORY_ROOT = "repository-root"
|
|
232
|
+
BOUNDARY_ORDINARY_DESCENDANT = "ordinary-descendant"
|
|
233
|
+
BOUNDARY_LINKED_WORKTREE = "linked-worktree"
|
|
234
|
+
BOUNDARY_NESTED_REPOSITORY = "nested-repository"
|
|
235
|
+
BOUNDARY_SUBMODULE = "submodule"
|
|
236
|
+
BOUNDARY_NON_GIT = "non-git"
|
|
237
|
+
BOUNDARY_EXTERNAL_SYMLINK = "external-symlink"
|
|
238
|
+
BOUNDARY_AMBIGUOUS_MULTI_ROOT = "ambiguous-multi-root"
|
|
239
|
+
_MAX_ROOTS = 16
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def posix_abs_path(path: str | None) -> str | None:
|
|
243
|
+
"""Absolute path with POSIX separators for JSON and matching text output."""
|
|
244
|
+
if path is None:
|
|
245
|
+
return None
|
|
246
|
+
return os.path.normpath(path).replace("\\", "/")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _same_path(left: str, right: str) -> bool:
|
|
250
|
+
return os.path.normcase(os.path.normpath(left)) == os.path.normcase(os.path.normpath(right))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def is_ambiguous_workspace_roots(raw: object) -> bool:
|
|
254
|
+
"""True when ``workspace_roots`` names two or more distinct realpath roots."""
|
|
255
|
+
if not isinstance(raw, list):
|
|
256
|
+
return False
|
|
257
|
+
seen: set[str] = set()
|
|
258
|
+
for item in raw[:_MAX_ROOTS]:
|
|
259
|
+
if not isinstance(item, str) or not item:
|
|
260
|
+
continue
|
|
261
|
+
try:
|
|
262
|
+
real = os.path.normcase(safe_realpath(item))
|
|
263
|
+
except (OSError, ValueError):
|
|
264
|
+
return True
|
|
265
|
+
seen.add(real)
|
|
266
|
+
if len(seen) > 1:
|
|
267
|
+
return True
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def is_external_symlink_anchor(raw: str) -> bool:
|
|
272
|
+
"""True when a symlink component of ``raw`` escapes the Git root that contains the link."""
|
|
273
|
+
try:
|
|
274
|
+
current = raw if os.path.isabs(raw) else os.path.join(os.getcwd(), raw)
|
|
275
|
+
current = os.path.abspath(current)
|
|
276
|
+
for _ in range(_MAX_ROOTS * 4):
|
|
277
|
+
if os.path.islink(current):
|
|
278
|
+
parent = os.path.dirname(current)
|
|
279
|
+
parent_loc = find_git_location(parent)
|
|
280
|
+
if parent_loc is not None:
|
|
281
|
+
real = safe_realpath(current)
|
|
282
|
+
top = parent_loc.top_level
|
|
283
|
+
prefix = top if top.endswith(os.sep) else top + os.sep
|
|
284
|
+
real_n, top_n, prefix_n = (
|
|
285
|
+
os.path.normcase(real),
|
|
286
|
+
os.path.normcase(top),
|
|
287
|
+
os.path.normcase(prefix),
|
|
288
|
+
)
|
|
289
|
+
if real_n != top_n and not real_n.startswith(prefix_n):
|
|
290
|
+
return True
|
|
291
|
+
parent = os.path.dirname(current)
|
|
292
|
+
if parent == current:
|
|
293
|
+
break
|
|
294
|
+
current = parent
|
|
295
|
+
except OSError:
|
|
296
|
+
return True
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _child_git_roots(start: str) -> list[str]:
|
|
301
|
+
"""Immediate child directories that have a ``.git`` entry (multi-root parent folder)."""
|
|
302
|
+
found: list[str] = []
|
|
303
|
+
try:
|
|
304
|
+
with os.scandir(start) as entries:
|
|
305
|
+
for entry in entries:
|
|
306
|
+
if not entry.is_dir(follow_symlinks=False):
|
|
307
|
+
continue
|
|
308
|
+
if os.path.lexists(os.path.join(entry.path, ".git")):
|
|
309
|
+
found.append(entry.path)
|
|
310
|
+
if len(found) > 1:
|
|
311
|
+
return found
|
|
312
|
+
except OSError:
|
|
313
|
+
return []
|
|
314
|
+
return found
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _gitfile_boundary(location: GitLocation) -> str:
|
|
318
|
+
entry = os.path.join(location.top_level, ".git")
|
|
319
|
+
try:
|
|
320
|
+
is_file = os.path.isfile(entry) and not os.path.isdir(entry)
|
|
321
|
+
except OSError:
|
|
322
|
+
is_file = False
|
|
323
|
+
if not is_file:
|
|
324
|
+
return BOUNDARY_REPOSITORY_ROOT
|
|
325
|
+
parts = location.git_dir.replace("\\", "/").split("/")
|
|
326
|
+
if "worktrees" in parts:
|
|
327
|
+
return BOUNDARY_LINKED_WORKTREE
|
|
328
|
+
if "modules" in parts:
|
|
329
|
+
return BOUNDARY_SUBMODULE
|
|
330
|
+
if not _same_path(location.git_dir, location.common_dir):
|
|
331
|
+
return BOUNDARY_LINKED_WORKTREE
|
|
332
|
+
return BOUNDARY_SUBMODULE
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _enclosing_git(top_level: str) -> GitLocation | None:
|
|
336
|
+
parent = os.path.dirname(top_level)
|
|
337
|
+
if parent == top_level:
|
|
338
|
+
return None
|
|
339
|
+
outer = find_git_location(parent)
|
|
340
|
+
if outer is None or _same_path(outer.top_level, top_level):
|
|
341
|
+
return None
|
|
342
|
+
return outer
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class Resolution:
|
|
346
|
+
"""Read-only repository resolution for ``doctor`` and ``validate`` (ADR 0009).
|
|
347
|
+
|
|
348
|
+
A plain class: this module stays stdlib-only for the hook import path.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
__slots__ = (
|
|
352
|
+
"boundary",
|
|
353
|
+
"common_dir",
|
|
354
|
+
"input_path",
|
|
355
|
+
"marker_path",
|
|
356
|
+
"marker_present",
|
|
357
|
+
"marker_valid",
|
|
358
|
+
"reason",
|
|
359
|
+
"resolved_path",
|
|
360
|
+
"status",
|
|
361
|
+
"top_level",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def __init__( # noqa: PLR0913, PLR0917
|
|
365
|
+
self,
|
|
366
|
+
input_path: str,
|
|
367
|
+
resolved_path: str,
|
|
368
|
+
top_level: str | None,
|
|
369
|
+
common_dir: str | None,
|
|
370
|
+
marker_path: str | None,
|
|
371
|
+
marker_present: bool,
|
|
372
|
+
marker_valid: bool,
|
|
373
|
+
boundary: str,
|
|
374
|
+
status: str,
|
|
375
|
+
reason: str,
|
|
376
|
+
) -> None:
|
|
377
|
+
self.input_path = input_path
|
|
378
|
+
self.resolved_path = resolved_path
|
|
379
|
+
self.top_level = top_level
|
|
380
|
+
self.common_dir = common_dir
|
|
381
|
+
self.marker_path = marker_path
|
|
382
|
+
self.marker_present = marker_present
|
|
383
|
+
self.marker_valid = marker_valid
|
|
384
|
+
self.boundary = boundary
|
|
385
|
+
self.status = status
|
|
386
|
+
self.reason = reason
|
|
387
|
+
|
|
388
|
+
def as_dict(self) -> dict[str, object]:
|
|
389
|
+
"""JSON-ready facts. Text output must render this same mapping."""
|
|
390
|
+
return {
|
|
391
|
+
"input_path": posix_abs_path(self.input_path),
|
|
392
|
+
"resolved_path": posix_abs_path(self.resolved_path),
|
|
393
|
+
"repository_root": posix_abs_path(self.top_level),
|
|
394
|
+
"common_dir": posix_abs_path(self.common_dir),
|
|
395
|
+
"marker_path": posix_abs_path(self.marker_path),
|
|
396
|
+
"marker_present": self.marker_present,
|
|
397
|
+
"marker_valid": self.marker_valid,
|
|
398
|
+
"boundary": self.boundary,
|
|
399
|
+
"status": self.status,
|
|
400
|
+
"reason": self.reason,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _marker_fields(top_level: str | None) -> tuple[str | None, bool, bool]:
|
|
405
|
+
if not top_level:
|
|
406
|
+
return None, False, False
|
|
407
|
+
marker = os.path.join(top_level, INSTALL_MARKER_DIR, INSTALL_MARKER_NAME)
|
|
408
|
+
try:
|
|
409
|
+
present = os.path.lexists(marker)
|
|
410
|
+
except OSError:
|
|
411
|
+
present = False
|
|
412
|
+
return marker, present, has_install_marker(top_level)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _fail( # noqa: PLR0913, PLR0917
|
|
416
|
+
input_path: str,
|
|
417
|
+
resolved_path: str,
|
|
418
|
+
boundary: str,
|
|
419
|
+
reason: str,
|
|
420
|
+
top_level: str | None = None,
|
|
421
|
+
common_dir: str | None = None,
|
|
422
|
+
) -> Resolution:
|
|
423
|
+
marker_path, present, valid = _marker_fields(top_level)
|
|
424
|
+
return Resolution(
|
|
425
|
+
input_path,
|
|
426
|
+
resolved_path,
|
|
427
|
+
top_level,
|
|
428
|
+
common_dir,
|
|
429
|
+
marker_path,
|
|
430
|
+
present,
|
|
431
|
+
valid,
|
|
432
|
+
boundary,
|
|
433
|
+
"fail",
|
|
434
|
+
reason,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _ok(
|
|
439
|
+
input_path: str,
|
|
440
|
+
resolved_path: str,
|
|
441
|
+
location: GitLocation,
|
|
442
|
+
boundary: str,
|
|
443
|
+
reason: str,
|
|
444
|
+
) -> Resolution:
|
|
445
|
+
marker_path, present, valid = _marker_fields(location.top_level)
|
|
446
|
+
return Resolution(
|
|
447
|
+
input_path,
|
|
448
|
+
resolved_path,
|
|
449
|
+
location.top_level,
|
|
450
|
+
location.common_dir,
|
|
451
|
+
marker_path,
|
|
452
|
+
present,
|
|
453
|
+
valid,
|
|
454
|
+
boundary,
|
|
455
|
+
"ok",
|
|
456
|
+
reason,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def inspect_repository( # noqa: PLR0911
|
|
461
|
+
start: str, workspace_roots: object = None
|
|
462
|
+
) -> Resolution:
|
|
463
|
+
"""Classify ``start`` using the Task 16 file-walk. Creates nothing. Never raises."""
|
|
464
|
+
input_path = start
|
|
465
|
+
try:
|
|
466
|
+
abs_input = start if os.path.isabs(start) else os.path.abspath(start)
|
|
467
|
+
except OSError:
|
|
468
|
+
abs_input = start
|
|
469
|
+
try:
|
|
470
|
+
resolved = safe_realpath(abs_input)
|
|
471
|
+
except (OSError, ValueError):
|
|
472
|
+
resolved = abs_input
|
|
473
|
+
if is_ambiguous_workspace_roots(workspace_roots):
|
|
474
|
+
return _fail(
|
|
475
|
+
input_path,
|
|
476
|
+
resolved,
|
|
477
|
+
BOUNDARY_AMBIGUOUS_MULTI_ROOT,
|
|
478
|
+
"ambiguous multi-root workspace",
|
|
479
|
+
)
|
|
480
|
+
if is_external_symlink_anchor(abs_input):
|
|
481
|
+
return _fail(
|
|
482
|
+
input_path,
|
|
483
|
+
resolved,
|
|
484
|
+
BOUNDARY_EXTERNAL_SYMLINK,
|
|
485
|
+
"path is an external symlink that leaves the containing repository",
|
|
486
|
+
)
|
|
487
|
+
location = find_git_location(resolved if os.path.isabs(resolved) else abs_input)
|
|
488
|
+
if location is None:
|
|
489
|
+
if len(_child_git_roots(resolved)) > 1:
|
|
490
|
+
return _fail(
|
|
491
|
+
input_path,
|
|
492
|
+
resolved,
|
|
493
|
+
BOUNDARY_AMBIGUOUS_MULTI_ROOT,
|
|
494
|
+
"ambiguous multi-root workspace",
|
|
495
|
+
)
|
|
496
|
+
return _fail(
|
|
497
|
+
input_path,
|
|
498
|
+
resolved,
|
|
499
|
+
BOUNDARY_NON_GIT,
|
|
500
|
+
"not inside a Git working tree",
|
|
501
|
+
)
|
|
502
|
+
kind = _gitfile_boundary(location)
|
|
503
|
+
nested = _enclosing_git(location.top_level) is not None
|
|
504
|
+
at_root = _same_path(resolved, location.top_level)
|
|
505
|
+
marker_valid = has_install_marker(location.top_level)
|
|
506
|
+
if kind == BOUNDARY_LINKED_WORKTREE:
|
|
507
|
+
if at_root:
|
|
508
|
+
return _ok(
|
|
509
|
+
input_path,
|
|
510
|
+
resolved,
|
|
511
|
+
location,
|
|
512
|
+
BOUNDARY_LINKED_WORKTREE,
|
|
513
|
+
"linked worktree root; runtime stays at the git common directory",
|
|
514
|
+
)
|
|
515
|
+
return _ok(
|
|
516
|
+
input_path,
|
|
517
|
+
resolved,
|
|
518
|
+
location,
|
|
519
|
+
BOUNDARY_ORDINARY_DESCENDANT,
|
|
520
|
+
"ordinary descendant of a linked worktree",
|
|
521
|
+
)
|
|
522
|
+
if kind == BOUNDARY_SUBMODULE:
|
|
523
|
+
return _ok(
|
|
524
|
+
input_path,
|
|
525
|
+
resolved,
|
|
526
|
+
location,
|
|
527
|
+
BOUNDARY_SUBMODULE,
|
|
528
|
+
"submodule or gitfile boundary; outer repository is not used",
|
|
529
|
+
)
|
|
530
|
+
if nested:
|
|
531
|
+
reason = (
|
|
532
|
+
"uninitialized inner repository; will not fall back to an outer repository"
|
|
533
|
+
if not marker_valid
|
|
534
|
+
else "nested repository; inner root is the boundary"
|
|
535
|
+
)
|
|
536
|
+
return _ok(
|
|
537
|
+
input_path,
|
|
538
|
+
resolved,
|
|
539
|
+
location,
|
|
540
|
+
BOUNDARY_NESTED_REPOSITORY if at_root else BOUNDARY_ORDINARY_DESCENDANT,
|
|
541
|
+
reason,
|
|
542
|
+
)
|
|
543
|
+
if at_root:
|
|
544
|
+
return _ok(
|
|
545
|
+
input_path,
|
|
546
|
+
resolved,
|
|
547
|
+
location,
|
|
548
|
+
BOUNDARY_REPOSITORY_ROOT,
|
|
549
|
+
(
|
|
550
|
+
"detected repository root"
|
|
551
|
+
if marker_valid
|
|
552
|
+
else "detected repository root; CursorFleet marker is absent"
|
|
553
|
+
),
|
|
554
|
+
)
|
|
555
|
+
return _ok(
|
|
556
|
+
input_path,
|
|
557
|
+
resolved,
|
|
558
|
+
location,
|
|
559
|
+
BOUNDARY_ORDINARY_DESCENDANT,
|
|
560
|
+
(
|
|
561
|
+
"ordinary descendant of an initialized enclosing repository"
|
|
562
|
+
if marker_valid
|
|
563
|
+
else "ordinary descendant of the detected repository; CursorFleet marker is absent"
|
|
564
|
+
),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def untrusted_reason(root: str) -> str | None: # noqa: PLR0911
|
|
569
|
+
"""Why ``root`` (the runtime directory) must not be written to, or ``None`` if it is fine.
|
|
570
|
+
|
|
571
|
+
A missing directory is fine (it will be created 0700 by us). An existing one must be a
|
|
572
|
+
real directory (no symlink or Windows junction) owned by the current user and, on POSIX,
|
|
573
|
+
not writable by group or others. Otherwise another account could have pre-planted
|
|
574
|
+
symlinks or forged spool files in a shared ``.git`` directory (security review SR-02).
|
|
575
|
+
One ``lstat``; hot-path safe.
|
|
576
|
+
"""
|
|
577
|
+
try:
|
|
578
|
+
info = os.lstat(root)
|
|
579
|
+
except FileNotFoundError:
|
|
580
|
+
return None
|
|
581
|
+
except OSError:
|
|
582
|
+
return "cannot be inspected"
|
|
583
|
+
if stat.S_ISLNK(info.st_mode):
|
|
584
|
+
return "is a symbolic link"
|
|
585
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
586
|
+
return "is not a directory"
|
|
587
|
+
if getattr(info, "st_file_attributes", 0) & _FILE_ATTRIBUTE_REPARSE_POINT:
|
|
588
|
+
return "is a reparse point (junction)"
|
|
589
|
+
if os.name == "posix":
|
|
590
|
+
geteuid = getattr(os, "geteuid", None)
|
|
591
|
+
if geteuid is not None and info.st_uid != geteuid():
|
|
592
|
+
return "is owned by another user"
|
|
593
|
+
if info.st_mode & 0o022:
|
|
594
|
+
return "is writable by group or others"
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def mkdir_private(path: str | os.PathLike[str]) -> None:
|
|
599
|
+
"""Create ``path`` (and missing parents) with mode 0700 regardless of umask.
|
|
600
|
+
|
|
601
|
+
Existing directories are left untouched (``doctor`` reports wrong modes). On
|
|
602
|
+
Windows the mode is a no-op; user-only ACLs are best effort and not set here.
|
|
603
|
+
"""
|
|
604
|
+
target = os.fspath(path)
|
|
605
|
+
missing: list[str] = []
|
|
606
|
+
current = target
|
|
607
|
+
while current and not os.path.isdir(current):
|
|
608
|
+
missing.append(current)
|
|
609
|
+
parent = os.path.dirname(current)
|
|
610
|
+
if parent == current:
|
|
611
|
+
break
|
|
612
|
+
current = parent
|
|
613
|
+
for directory in reversed(missing):
|
|
614
|
+
try:
|
|
615
|
+
os.mkdir(directory, DIR_MODE)
|
|
616
|
+
except FileExistsError:
|
|
617
|
+
continue
|
|
618
|
+
if os.name == "posix":
|
|
619
|
+
os.chmod(directory, DIR_MODE)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def write_private_file(path: str | os.PathLike[str], data: bytes, *, exclusive: bool) -> bool:
|
|
623
|
+
"""Write ``data`` to a new 0600 file. With ``exclusive`` never overwrite; return created."""
|
|
624
|
+
flags = os.O_WRONLY | os.O_CREAT | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
625
|
+
flags |= os.O_EXCL if exclusive else os.O_TRUNC
|
|
626
|
+
try:
|
|
627
|
+
fd = os.open(os.fspath(path), flags, FILE_MODE)
|
|
628
|
+
except FileExistsError:
|
|
629
|
+
return False
|
|
630
|
+
try:
|
|
631
|
+
if os.name == "posix":
|
|
632
|
+
fchmod = getattr(os, "fchmod", None)
|
|
633
|
+
if fchmod is not None:
|
|
634
|
+
fchmod(fd, FILE_MODE)
|
|
635
|
+
os.write(fd, data)
|
|
636
|
+
finally:
|
|
637
|
+
os.close(fd)
|
|
638
|
+
return True
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def ensure_runtime_root(paths: RuntimePaths) -> None:
|
|
642
|
+
"""Create the runtime root and write ``LAYOUT`` once."""
|
|
643
|
+
if not os.path.isdir(paths.root):
|
|
644
|
+
mkdir_private(paths.root)
|
|
645
|
+
write_private_file(paths.layout, (LAYOUT_VERSION + "\n").encode("ascii"), exclusive=True)
|