syncmill 0.2.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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/project.py
ADDED
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
"""Project discovery, identity, and first-run initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import tomllib
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
import tomlkit
|
|
18
|
+
import tomlkit.exceptions
|
|
19
|
+
|
|
20
|
+
from syncmill import teamfile
|
|
21
|
+
from syncmill.config import SyncmillConfig
|
|
22
|
+
from syncmill.git_env import isolated_git_env
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProjectError(RuntimeError):
|
|
29
|
+
"""The requested path cannot be used as a syncmill project."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ProjectDiscoveryError(ProjectError):
|
|
33
|
+
"""The requested path is not a Git repository at all.
|
|
34
|
+
|
|
35
|
+
A SUBCLASS, so every existing ``except ProjectError`` keeps catching it. The
|
|
36
|
+
distinction exists for the CLI's error wording: failing to find a repository
|
|
37
|
+
at the given path is not a *configuration* problem, and reporting it as one
|
|
38
|
+
("invalid target project configuration: not a Git repository") sends the
|
|
39
|
+
reader looking for a bad `syncmill.toml` that may not even exist. Everything
|
|
40
|
+
raised while READING a project that WAS found stays a plain ``ProjectError``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def git_root(path: Path) -> Path:
|
|
45
|
+
"""Resolve ``path``'s Git top-level, or raise ``ProjectError``.
|
|
46
|
+
|
|
47
|
+
``ProjectError`` is the ONLY failure: a missing or non-directory ``cwd`` and an
|
|
48
|
+
absent ``git`` executable both raise ``OSError`` from ``subprocess``, and letting
|
|
49
|
+
that through would mean every caller had to catch two unrelated error types to
|
|
50
|
+
ask one question — the CLI's target loader did not, and reported a nonexistent
|
|
51
|
+
``--repo`` path as a configuration problem.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
proc = subprocess.run(
|
|
55
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
56
|
+
cwd=str(path), env=isolated_git_env(), capture_output=True, text=True,
|
|
57
|
+
)
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
raise ProjectError(f"cannot inspect as a Git repository: {path} ({exc})") from exc
|
|
60
|
+
if proc.returncode != 0:
|
|
61
|
+
raise ProjectError(f"not a Git repository: {path}")
|
|
62
|
+
return Path(proc.stdout.strip()).resolve()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_MARKER_RELATIVE = ".syncmill/project.json"
|
|
66
|
+
_MARKER_DIR = ".syncmill"
|
|
67
|
+
_LOCAL_IGNORE_RELATIVE = ".syncmill/.gitignore"
|
|
68
|
+
|
|
69
|
+
# The id becomes a directory component under the runs/workspace state roots
|
|
70
|
+
# (`ArtifactStore` joins it straight into a path), so it must be a single
|
|
71
|
+
# conservative path token: no separators, no leading dot (which also excludes
|
|
72
|
+
# "." and ".."), bounded length. Everything syncmill mints — a uuid4, the
|
|
73
|
+
# 24-hex path digest — passes.
|
|
74
|
+
# ``fullmatch``, not ``match``: ``$`` also matches just before a trailing
|
|
75
|
+
# newline, so a marker holding "safe-id\n" would otherwise pass and become a
|
|
76
|
+
# directory name carrying a newline.
|
|
77
|
+
_SAFE_PROJECT_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|
|
78
|
+
|
|
79
|
+
# Windows refuses these as path components in any casing and with any
|
|
80
|
+
# extension, so an id built from one would break the artifact, session, and
|
|
81
|
+
# room constructors on a platform the foreground commands are meant to work on
|
|
82
|
+
# — rather than degrading to the path hash the way every other rejection does.
|
|
83
|
+
_WINDOWS_RESERVED_NAMES = frozenset(
|
|
84
|
+
{"CON", "PRN", "AUX", "NUL"}
|
|
85
|
+
| {f"COM{n}" for n in range(1, 10)}
|
|
86
|
+
| {f"LPT{n}" for n in range(1, 10)}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_windows_reserved(value: str) -> bool:
|
|
91
|
+
# A trailing period is refused for the same reason as the device names:
|
|
92
|
+
# Win32 strips it from a path component, so `cache.` would silently address
|
|
93
|
+
# `cache` — a different project's state — instead of failing loudly.
|
|
94
|
+
if value.endswith("."):
|
|
95
|
+
return True
|
|
96
|
+
return value.split(".", 1)[0].upper() in _WINDOWS_RESERVED_NAMES
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _aliases(one: Path, other: Path) -> bool:
|
|
100
|
+
"""True when both paths name the SAME file on this filesystem.
|
|
101
|
+
|
|
102
|
+
Asked instead of assuming, because a case-INSENSITIVE match is only
|
|
103
|
+
meaningful on a case-insensitive filesystem. On Linux `.Syncmill/` is a
|
|
104
|
+
genuinely different directory, and folding it onto the marker would let an
|
|
105
|
+
unrelated case-variant submodule block `init`, or an uppercase tracked
|
|
106
|
+
sibling discard a legitimate lowercase local marker.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
return os.path.samefile(one, other)
|
|
110
|
+
except OSError:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_GITLINK_MODE = "160000"
|
|
115
|
+
_SYMLINK_MODE = "120000"
|
|
116
|
+
_TREE_MODE = "040000"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_symlink_entry(meta: str) -> bool:
|
|
120
|
+
"""True for a Git entry recorded as a symlink (mode ``120000``).
|
|
121
|
+
|
|
122
|
+
Used unconditionally only for the ANCESTOR half of the HEAD walk: a link
|
|
123
|
+
entry never carries a tree, so descending into it would send ``ls-tree``
|
|
124
|
+
into a blob, answer "unknown", and block ``init --force`` outright — and a
|
|
125
|
+
link materialized as a regular file at the marker DIRECTORY's path cannot
|
|
126
|
+
hold a marker file inside it either way. Everywhere an entry is matched as
|
|
127
|
+
the marker FILE, use :func:`_symlink_entry_excluded` instead, which asks
|
|
128
|
+
the checkout what actually sits there. The ``160000`` gitlink case is
|
|
129
|
+
never excluded — there the content really does arrive with the clone.
|
|
130
|
+
"""
|
|
131
|
+
return meta.startswith(f"{_SYMLINK_MODE} ")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _symlink_entry_excluded(root: Path, meta: str, listed: str) -> bool:
|
|
135
|
+
"""True when a symlink entry supplies nothing but a LINK on this checkout.
|
|
136
|
+
|
|
137
|
+
A ``120000`` entry is excluded from alias matching only while the checkout
|
|
138
|
+
actually holds a symlink at its path. There, what Git supplies is the link
|
|
139
|
+
— the target's content is whatever the filesystem resolves it to, and
|
|
140
|
+
refusing to read through a link is already handled separately; left in,
|
|
141
|
+
such an entry would let a repository break an ordinary project by tracking
|
|
142
|
+
any symlink that happens to resolve to the marker (the round-14 exclusion).
|
|
143
|
+
|
|
144
|
+
But under ``core.symlinks=false`` (the common Windows checkout) Git
|
|
145
|
+
MATERIALIZES the entry: the link payload lands on disk as a regular file,
|
|
146
|
+
``read_text`` opens repository-controlled bytes, and no separate symlink
|
|
147
|
+
guard fires. That content did arrive with the clone, so the entry must
|
|
148
|
+
count like any tracked file — a repository could otherwise ship the marker
|
|
149
|
+
itself as a ``120000`` payload and have its id trusted. An absent path
|
|
150
|
+
participates too: exclusion is the trusting direction, so uncertainty
|
|
151
|
+
matches (``islink`` reports False on anything it cannot examine).
|
|
152
|
+
"""
|
|
153
|
+
if not meta.startswith(f"{_SYMLINK_MODE} "):
|
|
154
|
+
return False
|
|
155
|
+
return os.path.islink(root / listed)
|
|
156
|
+
|
|
157
|
+
# What, if anything, makes Git the SUPPLIER of the marker's content. ``None``
|
|
158
|
+
# means nothing does. The three named cases are all "do not trust the id"; only
|
|
159
|
+
# ``gitlink`` additionally means "do not write here" (see
|
|
160
|
+
# ``_reject_unwritable_marker_path``).
|
|
161
|
+
_MARKER_SUPPLY_TRACKED = "tracked"
|
|
162
|
+
_MARKER_SUPPLY_GITLINK = "gitlink"
|
|
163
|
+
_MARKER_SUPPLY_UNKNOWN = "unknown"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _marker_supply(root: Path) -> str | None:
|
|
167
|
+
"""How Git supplies the marker, or ``None`` when it does not.
|
|
168
|
+
|
|
169
|
+
"Supplied by Git" is two things, because the superproject index can carry
|
|
170
|
+
the marker's content without ever naming its path. The marker's own path
|
|
171
|
+
being tracked is the plain case. The other is a tracked GITLINK ancestor: a
|
|
172
|
+
populated ``.syncmill`` submodule is a single ``160000`` entry at
|
|
173
|
+
``.syncmill``, so nothing names ``.syncmill/project.json`` at all, yet the
|
|
174
|
+
file's content still arrives with the clone.
|
|
175
|
+
|
|
176
|
+
Only a listing that names neither means "not tracked"; a failure (not a
|
|
177
|
+
repository, no ``git``) means the question went unanswered, which distrusts
|
|
178
|
+
the marker — the path-hash fallback is always safe, a shipped id never is.
|
|
179
|
+
|
|
180
|
+
Both the INDEX and HEAD are consulted. Staging a shipped marker's removal
|
|
181
|
+
(``git rm --cached``) leaves the file in the worktree while taking it out
|
|
182
|
+
of the index, so an index-only question would call repository content
|
|
183
|
+
machine-local.
|
|
184
|
+
|
|
185
|
+
The pathspec carries ``:(icase)`` because on a case-insensitive filesystem
|
|
186
|
+
(the macOS and Windows defaults) a repository can commit
|
|
187
|
+
``.syncmill/Project.json``, which ``read_text`` opens through the lowercase
|
|
188
|
+
path while an exact-case query reports nothing. A case-VARIANT hit is then
|
|
189
|
+
confirmed with ``_aliases`` before it counts, since on a case-sensitive
|
|
190
|
+
filesystem it names a different file entirely.
|
|
191
|
+
|
|
192
|
+
Entries are matched by their FULL path rather than treating any hit under
|
|
193
|
+
the prefix as tracking — ``syncmill init`` writes a `.syncmill/.gitignore`
|
|
194
|
+
the project is meant to commit, and a tracked sibling says nothing about
|
|
195
|
+
the marker.
|
|
196
|
+
"""
|
|
197
|
+
index = _index_supply(root)
|
|
198
|
+
if index in {_MARKER_SUPPLY_GITLINK, _MARKER_SUPPLY_UNKNOWN}:
|
|
199
|
+
return index
|
|
200
|
+
# BOTH sources, even when the index already answered "tracked": during a
|
|
201
|
+
# submodule-to-directory conversion the index can hold a staged
|
|
202
|
+
# `.syncmill/project.json` while HEAD still records `.syncmill` as a
|
|
203
|
+
# gitlink and the populated submodule is still on disk. Only the gitlink
|
|
204
|
+
# answer stops `init` writing into that nested repository, so it — and an
|
|
205
|
+
# unanswered question — take precedence over a plain "tracked".
|
|
206
|
+
head = _head_supply(root)
|
|
207
|
+
if head in {_MARKER_SUPPLY_GITLINK, _MARKER_SUPPLY_UNKNOWN}:
|
|
208
|
+
return head
|
|
209
|
+
return index or head
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _alias_pathspecs(root: Path, relative_dir: str) -> list[str]:
|
|
213
|
+
"""Literal pathspecs for on-disk entries that ALIAS ``relative_dir``.
|
|
214
|
+
|
|
215
|
+
Bounded by the repository's top level, and empty in the ordinary case where
|
|
216
|
+
the only name for the directory is its own. ``:(literal)`` so a name
|
|
217
|
+
carrying glob characters is matched as itself.
|
|
218
|
+
"""
|
|
219
|
+
target = root / relative_dir
|
|
220
|
+
specs: list[str] = []
|
|
221
|
+
try:
|
|
222
|
+
names = [entry.name for entry in os.scandir(root)]
|
|
223
|
+
except OSError:
|
|
224
|
+
return specs
|
|
225
|
+
for name in names:
|
|
226
|
+
if name != relative_dir and _aliases(root / name, target):
|
|
227
|
+
specs.append(f":(literal){name}")
|
|
228
|
+
return specs
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _index_supply(root: Path) -> str | None:
|
|
232
|
+
"""The index half of :func:`_marker_supply`.
|
|
233
|
+
|
|
234
|
+
``:(icase)`` is Git's notion of a case match, which is NARROWER than the
|
|
235
|
+
filesystem's notion of an alias — NTFS resolves names through an uppercase
|
|
236
|
+
table that is not Unicode case folding, so ``.syncmıll`` can open as the
|
|
237
|
+
canonical path while the pathspec skips it. The listing is therefore also
|
|
238
|
+
given a literal pathspec for every on-disk name that actually aliases the
|
|
239
|
+
marker directory, which the filesystem is asked directly (see
|
|
240
|
+
``_alias_pathspecs``).
|
|
241
|
+
|
|
242
|
+
That is done instead of dropping the pathspec altogether, which would mean
|
|
243
|
+
a ``samefile`` pair per INDEX ENTRY — the whole repository — on a call
|
|
244
|
+
every command makes. Scoping by the aliases that exist on disk costs one
|
|
245
|
+
directory scan and covers the same ground: a name Git will not match but
|
|
246
|
+
the filesystem will resolve is, by definition, a name sitting there to be
|
|
247
|
+
found.
|
|
248
|
+
"""
|
|
249
|
+
argv = ["git", "ls-files", "-z", "--stage", "--", f":(icase){_MARKER_DIR}"]
|
|
250
|
+
argv.extend(_alias_pathspecs(root, _MARKER_DIR))
|
|
251
|
+
try:
|
|
252
|
+
proc = subprocess.run(
|
|
253
|
+
argv, cwd=str(root), env=isolated_git_env(), capture_output=True,
|
|
254
|
+
)
|
|
255
|
+
except OSError:
|
|
256
|
+
return _MARKER_SUPPLY_UNKNOWN
|
|
257
|
+
if proc.returncode != 0:
|
|
258
|
+
return _MARKER_SUPPLY_UNKNOWN
|
|
259
|
+
# Decoded with surrogateescape, never `text=True`: the listing covers the
|
|
260
|
+
# whole marker directory, and a tracked sibling whose name is not valid
|
|
261
|
+
# UTF-8 (legal on POSIX) would raise UnicodeDecodeError straight out of
|
|
262
|
+
# `project_id`, which is required to degrade rather than fail. The names
|
|
263
|
+
# being compared are pure ASCII, so an undecodable sibling simply cannot
|
|
264
|
+
# match.
|
|
265
|
+
listing = proc.stdout.decode("utf-8", errors="surrogateescape")
|
|
266
|
+
supply = None
|
|
267
|
+
for record in listing.split("\0"):
|
|
268
|
+
meta, _, path = record.partition("\t")
|
|
269
|
+
if not path or _symlink_entry_excluded(root, meta, path):
|
|
270
|
+
continue
|
|
271
|
+
if meta.startswith(f"{_GITLINK_MODE} ") and _names_marker_path(
|
|
272
|
+
root, path, _MARKER_DIR
|
|
273
|
+
):
|
|
274
|
+
# Reported in preference to a plain tracked path: it is the one
|
|
275
|
+
# that also makes the location unsafe to WRITE.
|
|
276
|
+
return _MARKER_SUPPLY_GITLINK
|
|
277
|
+
if _names_marker_path(root, path, _MARKER_RELATIVE):
|
|
278
|
+
supply = _MARKER_SUPPLY_TRACKED
|
|
279
|
+
return supply
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _names_marker_path(root: Path, listed: str, expected: str) -> bool:
|
|
283
|
+
"""True when a listed Git path refers to ``expected`` under ``root``.
|
|
284
|
+
|
|
285
|
+
An exact string match needs nothing more. EVERY other candidate is put to
|
|
286
|
+
the filesystem rather than pre-filtered by a case comparison, because
|
|
287
|
+
"these two names open the same file" is not a question Python's
|
|
288
|
+
``casefold`` can answer for someone else's filesystem: NTFS resolves names
|
|
289
|
+
through an uppercase table that is not Unicode case folding (dotless ``ı``
|
|
290
|
+
uppercases to ``I`` there), so ``.syncmıll/project.json`` can open through
|
|
291
|
+
the canonical path while the two spellings fold differently. ``_aliases``
|
|
292
|
+
costs a ``stat`` pair on a listing bounded by the marker's own directory
|
|
293
|
+
and the repository's top level, and returns False for a path that does not
|
|
294
|
+
exist — which is every unrelated entry.
|
|
295
|
+
"""
|
|
296
|
+
if listed == expected:
|
|
297
|
+
return True
|
|
298
|
+
return _aliases(root / listed, root / expected)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _head_supply(root: Path) -> str | None:
|
|
302
|
+
"""The committed-history half of :func:`_marker_supply`.
|
|
303
|
+
|
|
304
|
+
HEAD is ENUMERATED rather than probed by path, because a ``HEAD:<path>``
|
|
305
|
+
lookup is case-sensitive while the worktree spelling it would be built from
|
|
306
|
+
is whatever the checkout currently holds — a committed ``.SyncMill``
|
|
307
|
+
renamed to canonical casing after a staged removal would slip past an exact
|
|
308
|
+
probe. Listing instead lets the same ``_names_marker_path`` test the index
|
|
309
|
+
half uses decide, so both halves agree on when a case-variant counts.
|
|
310
|
+
|
|
311
|
+
The ancestor is examined separately from the file, because Git does not
|
|
312
|
+
traverse a gitlink: for a committed ``.syncmill`` submodule whose entry was
|
|
313
|
+
staged away (``git rm --cached .syncmill``), the populated worktree and its
|
|
314
|
+
shipped marker remain while the file has no path in HEAD at all.
|
|
315
|
+
"""
|
|
316
|
+
head = _git_query(root, ["rev-parse", "--quiet", "--verify", "HEAD"])
|
|
317
|
+
if head is None:
|
|
318
|
+
return _MARKER_SUPPLY_UNKNOWN
|
|
319
|
+
if head.returncode == 1:
|
|
320
|
+
# No commits yet — the ordinary `git init && syncmill init` sequence,
|
|
321
|
+
# which must not be mistaken for a broken repository.
|
|
322
|
+
return None
|
|
323
|
+
if head.returncode != 0:
|
|
324
|
+
return _MARKER_SUPPLY_UNKNOWN
|
|
325
|
+
top = _head_entries(root, "HEAD")
|
|
326
|
+
if top is None:
|
|
327
|
+
return _MARKER_SUPPLY_UNKNOWN
|
|
328
|
+
for meta, path in top:
|
|
329
|
+
if _is_symlink_entry(meta):
|
|
330
|
+
continue
|
|
331
|
+
if not _names_marker_path(root, path, _MARKER_DIR):
|
|
332
|
+
continue
|
|
333
|
+
if meta.startswith(f"{_GITLINK_MODE} "):
|
|
334
|
+
return _MARKER_SUPPLY_GITLINK
|
|
335
|
+
if not meta.startswith(f"{_TREE_MODE} "):
|
|
336
|
+
# A blob at the directory's path cannot hold the marker file, and
|
|
337
|
+
# descending would run `ls-tree` on it, fail, and report the whole
|
|
338
|
+
# repository unreadable — blocking `init` during the ordinary
|
|
339
|
+
# staged conversion of a tracked `.syncmill` FILE into the
|
|
340
|
+
# project directory.
|
|
341
|
+
continue
|
|
342
|
+
children = _head_entries(root, f"HEAD:{path}")
|
|
343
|
+
if children is None:
|
|
344
|
+
return _MARKER_SUPPLY_UNKNOWN
|
|
345
|
+
for child_meta, child in children:
|
|
346
|
+
# `ls-tree` of a subtree reports bare names, so the marker's own
|
|
347
|
+
# path is rebuilt before it is compared.
|
|
348
|
+
if _symlink_entry_excluded(root, child_meta, f"{path}/{child}"):
|
|
349
|
+
continue
|
|
350
|
+
if _names_marker_path(root, f"{path}/{child}", _MARKER_RELATIVE):
|
|
351
|
+
return _MARKER_SUPPLY_TRACKED
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _head_entries(root: Path, treeish: str) -> list[tuple[str, str]] | None:
|
|
356
|
+
"""``(meta, path)`` pairs of one committed tree, or ``None`` if unreadable."""
|
|
357
|
+
listing = _git_query(root, ["ls-tree", "-z", "--full-tree", treeish])
|
|
358
|
+
if listing is None or listing.returncode != 0:
|
|
359
|
+
return None
|
|
360
|
+
text = listing.stdout.decode("utf-8", errors="surrogateescape")
|
|
361
|
+
entries = []
|
|
362
|
+
for record in text.split("\0"):
|
|
363
|
+
meta, _, path = record.partition("\t")
|
|
364
|
+
if path:
|
|
365
|
+
entries.append((meta, path))
|
|
366
|
+
return entries
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _git_query(root: Path, args: list[str]) -> subprocess.CompletedProcess | None:
|
|
370
|
+
"""Run a read-only git query, or ``None`` when it could not run at all."""
|
|
371
|
+
try:
|
|
372
|
+
return subprocess.run(
|
|
373
|
+
["git", *args], cwd=str(root), env=isolated_git_env(), capture_output=True,
|
|
374
|
+
)
|
|
375
|
+
except OSError:
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _marker_path_is_symlinked(root: Path) -> bool:
|
|
380
|
+
"""True when any component of the marker path under ``root`` is a symlink.
|
|
381
|
+
|
|
382
|
+
A trackedness query is LEXICAL: it asks about the path as written. If a
|
|
383
|
+
checkout commits ``.syncmill`` as a symlink to a checked-in directory, the
|
|
384
|
+
index holds the symlink and the payload under two different names, the
|
|
385
|
+
query for ``.syncmill/project.json`` finds neither — and the read still
|
|
386
|
+
lands on repository-controlled content. Rather than chase where a link
|
|
387
|
+
goes, refuse to read through one at all: syncmill's own ``init`` writes a
|
|
388
|
+
real directory holding a real file, so a link here is never syncmill's.
|
|
389
|
+
A missing path is not a symlink, and the ordinary no-marker case simply
|
|
390
|
+
falls through to the read, which then fails.
|
|
391
|
+
|
|
392
|
+
Only components BELOW ``root`` are examined — the checkout itself may
|
|
393
|
+
legitimately sit under a symlinked path (``/tmp`` on macOS), which says
|
|
394
|
+
nothing about who controls the marker.
|
|
395
|
+
"""
|
|
396
|
+
return _has_symlinked_component(root, _MARKER_RELATIVE)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _has_symlinked_component(root: Path, relative: str) -> bool:
|
|
400
|
+
"""True when any component of ``relative`` under ``root`` is a link."""
|
|
401
|
+
current = root
|
|
402
|
+
for part in Path(relative).parts:
|
|
403
|
+
current = current / part
|
|
404
|
+
try:
|
|
405
|
+
# ``is_junction`` as well as ``is_symlink``: an NTFS directory
|
|
406
|
+
# junction is a traversable reparse point that ``is_symlink``
|
|
407
|
+
# reports as False, so on Windows a ``.syncmill`` junction could
|
|
408
|
+
# point outside the repository through a check that called the
|
|
409
|
+
# path ordinary.
|
|
410
|
+
if current.is_symlink() or current.is_junction():
|
|
411
|
+
return True
|
|
412
|
+
except OSError:
|
|
413
|
+
return True
|
|
414
|
+
return False
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _marker_project_id(root: Path) -> str | None:
|
|
418
|
+
"""Return the machine-local marker's id, or ``None`` when it isn't trusted.
|
|
419
|
+
|
|
420
|
+
The marker is machine-local state ``syncmill init`` writes (0600), not
|
|
421
|
+
project source — but it lives inside the working tree, so a cloned
|
|
422
|
+
repository can SHIP one and bind this machine's state roots to an id chosen
|
|
423
|
+
by whoever committed it. Three gates before the id is honored: the id must
|
|
424
|
+
be a safe single path component (it becomes a directory name — ``../..``
|
|
425
|
+
must never traverse the state roots), the path must not be reached through
|
|
426
|
+
a symlink (which would put the read and the lexical trackedness query on
|
|
427
|
+
different files), and the file must not be tracked by Git (a tracked marker
|
|
428
|
+
arrived with the clone, not from this machine's ``init``; asked per read,
|
|
429
|
+
not recorded at init, because the file can become tracked at any time
|
|
430
|
+
after). A rejected marker degrades to the path-hash id with a warning,
|
|
431
|
+
never an error — identity must not brick every command over a hostile or
|
|
432
|
+
damaged tree.
|
|
433
|
+
"""
|
|
434
|
+
marker = root / _MARKER_RELATIVE
|
|
435
|
+
if _marker_path_is_symlinked(root):
|
|
436
|
+
logger.warning(
|
|
437
|
+
"ignoring %s: it is reached through a symlink, so its content is "
|
|
438
|
+
"not necessarily this machine's own project marker",
|
|
439
|
+
marker,
|
|
440
|
+
)
|
|
441
|
+
return None
|
|
442
|
+
try:
|
|
443
|
+
data = json.loads(marker.read_text(encoding="utf-8"))
|
|
444
|
+
except (OSError, ValueError) as exc:
|
|
445
|
+
# A malformed marker is not an error — the whole content is
|
|
446
|
+
# repository-controllable, so raising here would let a checkout
|
|
447
|
+
# turn every syncmill command into a traceback.
|
|
448
|
+
logger.debug("no usable project marker at %s: %s", marker, exc)
|
|
449
|
+
return None
|
|
450
|
+
# The top level is checked BEFORE reaching for a key: valid JSON that is
|
|
451
|
+
# not an object (`[]`, `"x"`, `null`) has no `.get`, and AttributeError is
|
|
452
|
+
# neither OSError nor ValueError — the same trap `initialize_project`'s own
|
|
453
|
+
# probe was already written against.
|
|
454
|
+
value = data.get("id") if isinstance(data, dict) else None
|
|
455
|
+
if not isinstance(value, str) or not value:
|
|
456
|
+
return None
|
|
457
|
+
if not _SAFE_PROJECT_ID.fullmatch(value) or _is_windows_reserved(value):
|
|
458
|
+
logger.warning("ignoring %s: id %r is not a safe path token", marker, value)
|
|
459
|
+
return None
|
|
460
|
+
if _marker_supply(root) is not None:
|
|
461
|
+
logger.warning(
|
|
462
|
+
"ignoring %s: it is supplied by Git, so it shipped with the "
|
|
463
|
+
"repository instead of coming from this machine's `syncmill init`",
|
|
464
|
+
marker,
|
|
465
|
+
)
|
|
466
|
+
return None
|
|
467
|
+
return value
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def project_id(root: Path, configured: str | None = None) -> str:
|
|
471
|
+
if configured:
|
|
472
|
+
# Explicit config is the operator's own trusted input (the same file
|
|
473
|
+
# carries [gate] commands), so it is not second-guessed here.
|
|
474
|
+
return configured
|
|
475
|
+
value = _marker_project_id(root)
|
|
476
|
+
if value is not None:
|
|
477
|
+
return value
|
|
478
|
+
return hashlib.sha256(str(root.resolve()).encode()).hexdigest()[:24]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def configure_project_memory(config: SyncmillConfig, root: Path) -> None:
|
|
482
|
+
"""Move untouched legacy memory defaults into an initialized project.
|
|
483
|
+
|
|
484
|
+
Project-scoped memory is the repository contract: an initialized project
|
|
485
|
+
that leaves ``[memory]`` at its generated defaults must read/write
|
|
486
|
+
``<repo>/.syncmill/memory``, never the ambient ``~/.memtomem`` store.
|
|
487
|
+
Idempotent — a second call sees ``memory_dir`` already bound.
|
|
488
|
+
"""
|
|
489
|
+
if (
|
|
490
|
+
config.project.id
|
|
491
|
+
and config.memory.memory_dir is None
|
|
492
|
+
and config.memory.db_path == "~/.memtomem/memtomem.db"
|
|
493
|
+
):
|
|
494
|
+
memory_root = (root / config.project.memory_dir).resolve()
|
|
495
|
+
config.memory.memory_dir = str(memory_root)
|
|
496
|
+
config.memory.db_path = str(memory_root / "memtomem.db")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def resolve_project_state_paths(config: SyncmillConfig, root: Path) -> SyncmillConfig:
|
|
500
|
+
"""Bind relative durable-state paths to the project that owns the config.
|
|
501
|
+
|
|
502
|
+
``ArtifactStore`` and the workspace helpers eventually call ``resolve()``.
|
|
503
|
+
Leaving a configured relative path untouched until then would therefore make
|
|
504
|
+
its meaning depend on the caller's current working directory. Resolve both
|
|
505
|
+
correctness-state roots at the project boundary so regular runs, Rooms, and
|
|
506
|
+
isolated sessions all address the same storage.
|
|
507
|
+
"""
|
|
508
|
+
base = root.expanduser().resolve()
|
|
509
|
+
state_paths: list[tuple[str, Path]] = []
|
|
510
|
+
for label, section in (("runs.root", config.runs), ("workspace.root", config.workspace)):
|
|
511
|
+
value = Path(section.root).expanduser()
|
|
512
|
+
if not value.is_absolute():
|
|
513
|
+
value = base / value
|
|
514
|
+
resolved = value.resolve()
|
|
515
|
+
section.root = str(resolved)
|
|
516
|
+
state_paths.append((label, resolved))
|
|
517
|
+
repositories = {base}
|
|
518
|
+
configured_repo = Path(config.repo_root).expanduser()
|
|
519
|
+
if not configured_repo.is_absolute():
|
|
520
|
+
configured_repo = base / configured_repo
|
|
521
|
+
repositories.add(configured_repo.resolve())
|
|
522
|
+
for label, state_path in state_paths:
|
|
523
|
+
for repository in repositories:
|
|
524
|
+
_require_ignored_state_root(repository, state_path, label)
|
|
525
|
+
return config
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _require_ignored_state_root(
|
|
529
|
+
repository: Path, state_root: Path, label: str
|
|
530
|
+
) -> None:
|
|
531
|
+
"""Reject correctness state that would become repository source drift."""
|
|
532
|
+
try:
|
|
533
|
+
relative = state_root.relative_to(repository)
|
|
534
|
+
except ValueError:
|
|
535
|
+
return
|
|
536
|
+
if not relative.parts or relative.parts[0] == ".git":
|
|
537
|
+
raise ProjectError(f"{label} must be outside the repository or Git-ignored")
|
|
538
|
+
env = isolated_git_env()
|
|
539
|
+
relative_text = relative.as_posix().rstrip("/")
|
|
540
|
+
probes = (relative_text, f"{relative_text}/", f"{relative_text}/.syncmill-probe")
|
|
541
|
+
for probe in probes:
|
|
542
|
+
ignored = subprocess.run(
|
|
543
|
+
["git", "check-ignore", "-q", "--no-index", "--", probe],
|
|
544
|
+
cwd=str(repository),
|
|
545
|
+
env=env,
|
|
546
|
+
capture_output=True,
|
|
547
|
+
)
|
|
548
|
+
if ignored.returncode == 0:
|
|
549
|
+
return
|
|
550
|
+
if ignored.returncode not in {1}:
|
|
551
|
+
raise ProjectError(f"could not verify Git ignore status for {label}")
|
|
552
|
+
raise ProjectError(f"{label} inside the repository must be Git-ignored")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@contextmanager
|
|
556
|
+
def _cwd(path: Path):
|
|
557
|
+
previous = Path.cwd()
|
|
558
|
+
os.chdir(path)
|
|
559
|
+
try:
|
|
560
|
+
yield
|
|
561
|
+
finally:
|
|
562
|
+
os.chdir(previous)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def load_project_config(
|
|
566
|
+
path: Path, *, repo_root_override: str | None = None
|
|
567
|
+
) -> tuple[Path, SyncmillConfig]:
|
|
568
|
+
"""Load env > target-project TOML > defaults, independent of caller cwd.
|
|
569
|
+
|
|
570
|
+
``repo_root_override`` carries ``run``'s explicit CLI ``--repo`` value, where
|
|
571
|
+
the flag names the repository to WORK ON and is documented to beat the
|
|
572
|
+
environment, so it must replace an ambient ``SYNCMILL_REPO_ROOT`` BEFORE
|
|
573
|
+
``repo_root`` is resolved/validated here — otherwise a stale exported path
|
|
574
|
+
fails the load the flag should have won.
|
|
575
|
+
|
|
576
|
+
Callers that pass NO override mean something different by ``--repo``: which
|
|
577
|
+
PROJECT's configuration to load. Their ``repo_root`` then resolves normally
|
|
578
|
+
(env > that project's file > default), which is what lets a project's config
|
|
579
|
+
point at a sibling checkout — the relative-collaboration layout, where owner
|
|
580
|
+
A's project directs work at repository B. Pinning ``repo_root`` to the flag
|
|
581
|
+
for those callers would silently retarget them at themselves.
|
|
582
|
+
"""
|
|
583
|
+
try:
|
|
584
|
+
root = git_root(path.resolve())
|
|
585
|
+
except ProjectError as exc:
|
|
586
|
+
# Discovery, not configuration — see ProjectDiscoveryError. The later
|
|
587
|
+
# git_root call resolves the CONFIGURED repo_root and stays a plain
|
|
588
|
+
# ProjectError, because a config value naming a non-repository is a
|
|
589
|
+
# configuration error.
|
|
590
|
+
raise ProjectDiscoveryError(str(exc)) from exc
|
|
591
|
+
with _cwd(root):
|
|
592
|
+
config = SyncmillConfig()
|
|
593
|
+
if repo_root_override is not None:
|
|
594
|
+
config.repo_root = repo_root_override
|
|
595
|
+
configured_root = Path(config.repo_root).expanduser()
|
|
596
|
+
if not configured_root.is_absolute():
|
|
597
|
+
configured_root = root / configured_root
|
|
598
|
+
config.repo_root = str(git_root(configured_root.resolve()))
|
|
599
|
+
resolve_project_state_paths(config, root)
|
|
600
|
+
return root, config
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
_NEW_PROJECT_CONFIG = (
|
|
604
|
+
"config_version = 2\n"
|
|
605
|
+
"repo_root = \".\"\n"
|
|
606
|
+
"default_strategy = \"route\"\n\n"
|
|
607
|
+
"[project]\n"
|
|
608
|
+
"id = \"{pid}\"\n"
|
|
609
|
+
"memory_dir = \".syncmill/memory\"\n\n"
|
|
610
|
+
"[gate]\n"
|
|
611
|
+
"commands = []\n"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
_SUPPORTED_CONFIG_VERSION = 2
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _write_fresh_config(config_path: Path, pid: str) -> None:
|
|
619
|
+
"""Write the starter config through the one module that owns toml writing.
|
|
620
|
+
|
|
621
|
+
Parsed and re-dumped rather than ``write_text``-ed so this branch gets the same
|
|
622
|
+
atomic temp+replace as every other config write — an interrupted `init` must not
|
|
623
|
+
leave a half-written active config."""
|
|
624
|
+
doc = tomlkit.parse(_NEW_PROJECT_CONFIG.format(pid=pid))
|
|
625
|
+
try:
|
|
626
|
+
teamfile.write_document(config_path, doc)
|
|
627
|
+
except teamfile.TeamFileError as exc:
|
|
628
|
+
raise ProjectError(str(exc)) from exc
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _reserve_backup_path(config_path: Path) -> Path:
|
|
632
|
+
"""Claim a `.bak` name atomically and return it.
|
|
633
|
+
|
|
634
|
+
A second repair must not overwrite the first one's backup — that copy is the
|
|
635
|
+
user's only remaining record of the config being replaced. Testing `exists()`
|
|
636
|
+
and then calling `os.replace` is check-then-use: two concurrent `init --force`
|
|
637
|
+
runs can both pick `.bak`, and the second's rename silently overwrites the
|
|
638
|
+
first's backup (`os.replace` is happy to). `O_CREAT|O_EXCL` makes the name ours
|
|
639
|
+
before we move anything onto it."""
|
|
640
|
+
counter = 0
|
|
641
|
+
while True:
|
|
642
|
+
suffix = ".bak" if counter == 0 else f".bak.{counter}"
|
|
643
|
+
candidate = config_path.with_suffix(config_path.suffix + suffix)
|
|
644
|
+
try:
|
|
645
|
+
os.close(os.open(str(candidate), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
|
|
646
|
+
return candidate
|
|
647
|
+
except FileExistsError:
|
|
648
|
+
counter += 1
|
|
649
|
+
except OSError as exc:
|
|
650
|
+
raise ProjectError(
|
|
651
|
+
f"cannot reserve a backup name beside {config_path}: {exc}"
|
|
652
|
+
) from exc
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _project_table(doc, key: str, config_path: Path):
|
|
656
|
+
"""Return ``doc[key]`` as a table, creating it only when the key is ABSENT.
|
|
657
|
+
|
|
658
|
+
A key holding something else (a scalar, an array of tables) is REFUSED, never
|
|
659
|
+
replaced: overwriting it would silently destroy whatever the user had there,
|
|
660
|
+
which is the failure this whole change exists to stop. ``dict`` covers tomlkit's
|
|
661
|
+
``Table`` and ``InlineTable`` and excludes an array-of-tables."""
|
|
662
|
+
value = doc.get(key)
|
|
663
|
+
if value is None:
|
|
664
|
+
table = tomlkit.table()
|
|
665
|
+
doc[key] = table
|
|
666
|
+
return table
|
|
667
|
+
if isinstance(value, dict):
|
|
668
|
+
return value
|
|
669
|
+
raise ProjectError(
|
|
670
|
+
f"{config_path} has a '{key}' key that is not a table — fix or remove it "
|
|
671
|
+
f"before re-initializing"
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _prepare_project_config(config_path: Path, pid: str):
|
|
676
|
+
"""Validate and stage the config write, MERGING into an existing file.
|
|
677
|
+
|
|
678
|
+
``init --force`` used to write the template over whatever was there, keeping
|
|
679
|
+
only ``project.id`` — so a re-init silently discarded the user's gate commands,
|
|
680
|
+
per-agent settings, strategy, model routing, and team profiles. Forcing is a
|
|
681
|
+
repair verb, not a reset one: it fills in only the keys the project needs and
|
|
682
|
+
leaves every other key (and every comment) alone.
|
|
683
|
+
|
|
684
|
+
Only a PARSE failure moves the file aside (to a non-colliding ``.bak``) and
|
|
685
|
+
starts fresh. A file we merely could not READ is an error, not a licence to
|
|
686
|
+
replace it — the content may be perfectly good.
|
|
687
|
+
|
|
688
|
+
Returns a zero-argument COMMITTER that performs the actual write; every
|
|
689
|
+
refusal — an unreadable file, a bad ``config_version``, a non-table
|
|
690
|
+
``project``/``gate`` key — happens here, before it, so the caller can
|
|
691
|
+
stage its other writes knowing the config will not reject them into a
|
|
692
|
+
half-mutated tree (round 19). Even the unparseable-file backup move is
|
|
693
|
+
deferred into the committer: a later prepare-stage failure must leave the
|
|
694
|
+
unparseable original in place, not moved aside with nothing written.
|
|
695
|
+
|
|
696
|
+
Both this call and the committer must run under ONE
|
|
697
|
+
``teamfile.config_write_lock`` span, so the read here and the write there
|
|
698
|
+
are one transaction and a `team save` landing between them cannot be
|
|
699
|
+
discarded (the writers' own re-entrant acquisitions inside are then free).
|
|
700
|
+
"""
|
|
701
|
+
if not config_path.exists():
|
|
702
|
+
return lambda: _write_fresh_config(config_path, pid)
|
|
703
|
+
try:
|
|
704
|
+
text = config_path.read_text(encoding="utf-8")
|
|
705
|
+
except OSError as exc:
|
|
706
|
+
raise ProjectError(f"cannot read {config_path}: {exc}") from exc
|
|
707
|
+
try:
|
|
708
|
+
doc = tomlkit.parse(text)
|
|
709
|
+
except tomlkit.exceptions.ParseError:
|
|
710
|
+
# ONLY a parse error moves the file aside. A blanket `except Exception`
|
|
711
|
+
# would treat any unexpected failure in the parser as proof the user's
|
|
712
|
+
# config is malformed, and replace a file that was fine.
|
|
713
|
+
return lambda: _replace_unparseable_config(config_path, pid)
|
|
714
|
+
_validate_and_merge_config_doc(doc, config_path, pid)
|
|
715
|
+
|
|
716
|
+
def _commit() -> None:
|
|
717
|
+
try:
|
|
718
|
+
teamfile.write_document(config_path, doc)
|
|
719
|
+
except teamfile.TeamFileError as exc:
|
|
720
|
+
raise ProjectError(str(exc)) from exc
|
|
721
|
+
|
|
722
|
+
return _commit
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _replace_unparseable_config(config_path: Path, pid: str) -> None:
|
|
726
|
+
"""Move an unparseable config aside and write the starter one."""
|
|
727
|
+
backup = _reserve_backup_path(config_path)
|
|
728
|
+
try:
|
|
729
|
+
os.replace(config_path, backup)
|
|
730
|
+
except OSError as exc:
|
|
731
|
+
# Remove the placeholder we reserved: leaving it behind puts an EMPTY
|
|
732
|
+
# `.bak` beside a config that is still in place, which reads as "your
|
|
733
|
+
# config was backed up and lost". Best-effort, and never masking the
|
|
734
|
+
# failure that brought us here.
|
|
735
|
+
try:
|
|
736
|
+
backup.unlink(missing_ok=True)
|
|
737
|
+
except OSError:
|
|
738
|
+
pass
|
|
739
|
+
raise ProjectError(
|
|
740
|
+
f"{config_path} is unparseable and could not be moved aside: {exc}"
|
|
741
|
+
) from exc
|
|
742
|
+
_write_fresh_config(config_path, pid)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _validate_and_merge_config_doc(doc, config_path: Path, pid: str) -> None:
|
|
746
|
+
"""Refuse a config `init` must not touch; otherwise merge in place."""
|
|
747
|
+
if "config_version" in doc:
|
|
748
|
+
version = doc["config_version"]
|
|
749
|
+
# The config model COERCES `3.0` and `"3"` to 3, so an isinstance(int) test
|
|
750
|
+
# lets those bypass the guard and be relabelled as 2 — and `true` is an int
|
|
751
|
+
# in Python, which would pass as version 1. Anything that is not a plain
|
|
752
|
+
# integer is refused outright rather than guessed at.
|
|
753
|
+
if isinstance(version, bool) or not isinstance(version, int):
|
|
754
|
+
raise ProjectError(
|
|
755
|
+
f"{config_path} declares a config_version that is not an integer "
|
|
756
|
+
f"({version!r}) — fix it before re-initializing"
|
|
757
|
+
)
|
|
758
|
+
if version < 1:
|
|
759
|
+
# `SyncmillConfig` requires >= 1, so preserving a 0 or negative value
|
|
760
|
+
# would leave `init --force` reporting success over a config that no
|
|
761
|
+
# command can load.
|
|
762
|
+
raise ProjectError(
|
|
763
|
+
f"{config_path} declares config_version {version}, which is not a "
|
|
764
|
+
f"valid version — fix it before re-initializing"
|
|
765
|
+
)
|
|
766
|
+
if version > _SUPPORTED_CONFIG_VERSION:
|
|
767
|
+
# Stamping this file as version 2 would relabel a schema we do not
|
|
768
|
+
# understand — the relabelling is the kind of quiet damage this change
|
|
769
|
+
# is about. Refuse instead.
|
|
770
|
+
raise ProjectError(
|
|
771
|
+
f"{config_path} declares config_version {version}, newer than this "
|
|
772
|
+
f"syncmill writes ({_SUPPORTED_CONFIG_VERSION}) — upgrade syncmill, "
|
|
773
|
+
f"or move the file aside to re-initialize"
|
|
774
|
+
)
|
|
775
|
+
doc.setdefault("config_version", _SUPPORTED_CONFIG_VERSION)
|
|
776
|
+
doc.setdefault("repo_root", ".")
|
|
777
|
+
doc.setdefault("default_strategy", "route")
|
|
778
|
+
project = _project_table(doc, "project", config_path)
|
|
779
|
+
project["id"] = pid
|
|
780
|
+
project.setdefault("memory_dir", ".syncmill/memory")
|
|
781
|
+
gate = _project_table(doc, "gate", config_path)
|
|
782
|
+
gate.setdefault("commands", [])
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _prepare_marker(marker: Path, pid: str) -> Path:
|
|
786
|
+
"""Stage the marker as a completed sibling file, ready to swap in.
|
|
787
|
+
|
|
788
|
+
Content and permissions are settled BEFORE anything else is written, so
|
|
789
|
+
the only marker step left at the end of ``init`` is one ``os.replace`` —
|
|
790
|
+
a failure this staging can hit (an unwritable marker directory) aborts
|
|
791
|
+
before any other file changed, instead of after ``syncmill.toml`` was
|
|
792
|
+
rewritten, where a retry without ``--force`` would claim the project is
|
|
793
|
+
already initialized (round 19). ``mkstemp`` creates the file ``0600``,
|
|
794
|
+
the marker's required mode.
|
|
795
|
+
"""
|
|
796
|
+
try:
|
|
797
|
+
fd, tmp = tempfile.mkstemp(
|
|
798
|
+
dir=str(marker.parent), prefix=".project-", suffix=".tmp"
|
|
799
|
+
)
|
|
800
|
+
except OSError as exc:
|
|
801
|
+
raise ProjectError(f"cannot stage {marker}: {exc}") from exc
|
|
802
|
+
try:
|
|
803
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
804
|
+
handle.write(json.dumps({"id": pid}, indent=2) + "\n")
|
|
805
|
+
except OSError as exc:
|
|
806
|
+
try:
|
|
807
|
+
os.unlink(tmp)
|
|
808
|
+
except OSError:
|
|
809
|
+
pass
|
|
810
|
+
raise ProjectError(f"cannot stage {marker}: {exc}") from exc
|
|
811
|
+
return Path(tmp)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _reject_unwritable_marker_path(root: Path) -> None:
|
|
815
|
+
"""Refuse to CREATE the marker where the path is not ours to write through.
|
|
816
|
+
|
|
817
|
+
Reading such a path merely distrusts its content; writing acts on whatever
|
|
818
|
+
it leads to. A symlinked component makes ``write_text``/``chmod`` land on a
|
|
819
|
+
file the checkout chose — possibly outside the repository entirely — and a
|
|
820
|
+
``.syncmill`` submodule would take an unrelated repository's working tree
|
|
821
|
+
dirty. ``init`` is an explicit foreground command, so this raises rather
|
|
822
|
+
than degrading: silently minting an id somewhere else is worse than saying
|
|
823
|
+
the tree needs fixing first.
|
|
824
|
+
|
|
825
|
+
An ordinary TRACKED marker is deliberately not refused — it is a normal
|
|
826
|
+
local file, and rewriting it with a freshly minted id is exactly the
|
|
827
|
+
upgrade path for a project that committed one.
|
|
828
|
+
"""
|
|
829
|
+
# The ignore file beside the marker is written too (created, and appended
|
|
830
|
+
# to), so it needs the same guard: a checkout can leave the marker path
|
|
831
|
+
# ordinary and track `.syncmill/.gitignore` as a symlink, and `init` would
|
|
832
|
+
# then append its rules to a file of the checkout's choosing.
|
|
833
|
+
for relative in (_MARKER_RELATIVE, _LOCAL_IGNORE_RELATIVE):
|
|
834
|
+
if _has_symlinked_component(root, relative):
|
|
835
|
+
raise ProjectError(
|
|
836
|
+
f"{root / relative} is reached through a symlink — remove it "
|
|
837
|
+
f"before initializing, so syncmill writes where it belongs"
|
|
838
|
+
)
|
|
839
|
+
# The marker path itself must not be a DIRECTORY (including a nested
|
|
840
|
+
# checkout mounted exactly there): the final atomic swap can only replace
|
|
841
|
+
# a file, and discovering that at the end of `init` would leave the other
|
|
842
|
+
# artifacts already written (round 20). A symlink at this path was
|
|
843
|
+
# already refused above, so `is_dir` here means a real directory.
|
|
844
|
+
if (root / _MARKER_RELATIVE).is_dir():
|
|
845
|
+
raise ProjectError(
|
|
846
|
+
f"{root / _MARKER_RELATIVE} is a directory — move it aside before "
|
|
847
|
+
f"initializing, so syncmill can write its marker file there"
|
|
848
|
+
)
|
|
849
|
+
# UNKNOWN is refused with the confirmed gitlink: it means the question
|
|
850
|
+
# could not be answered, and `.syncmill` may well BE a submodule. Reading
|
|
851
|
+
# fails safe by falling back to the path hash, but writing has no such
|
|
852
|
+
# fallback — the cross-repository write would simply happen. `git_root`
|
|
853
|
+
# has already succeeded by here, so an unanswered question means a
|
|
854
|
+
# genuinely broken repository rather than a missing `git`.
|
|
855
|
+
#
|
|
856
|
+
# The two sources are asked SEPARATELY here, unlike the read path's
|
|
857
|
+
# `_marker_supply`: a gitlink that survives only in HEAD blocks the write
|
|
858
|
+
# only while the nested checkout is still on disk. After a full removal
|
|
859
|
+
# (`git rm .syncmill` — index entry and worktree both gone) there is no
|
|
860
|
+
# other repository left to write into, and refusing would force an
|
|
861
|
+
# intermediate commit into the ordinary submodule-to-directory
|
|
862
|
+
# conversion. The read path stays conservative — a historical gitlink
|
|
863
|
+
# still distrusts a shipped id — but "do not write here" is about where
|
|
864
|
+
# the bytes LAND, which only the present tree can answer.
|
|
865
|
+
# The nested-checkout probe stands ON ITS OWN, not only under a HEAD
|
|
866
|
+
# gitlink: an untracked embedded checkout, or a former submodule kept on
|
|
867
|
+
# disk after its removal was COMMITTED, is named by no gitlink anywhere —
|
|
868
|
+
# both halves answer None — yet is still another repository's working
|
|
869
|
+
# tree (round 17). The gitlink answers add what the filesystem cannot
|
|
870
|
+
# see: an index gitlink refuses even before `submodule update` populates
|
|
871
|
+
# the checkout.
|
|
872
|
+
index = _index_supply(root)
|
|
873
|
+
head = _head_supply(root)
|
|
874
|
+
refused = (
|
|
875
|
+
index in {_MARKER_SUPPLY_GITLINK, _MARKER_SUPPLY_UNKNOWN}
|
|
876
|
+
or head == _MARKER_SUPPLY_UNKNOWN
|
|
877
|
+
or _nested_checkout_present(root)
|
|
878
|
+
)
|
|
879
|
+
if refused:
|
|
880
|
+
raise ProjectError(
|
|
881
|
+
f"{root / _MARKER_DIR} is a Git submodule or embedded repository, "
|
|
882
|
+
f"or its status could not be read — syncmill will not write its "
|
|
883
|
+
f"project marker into another repository's working tree"
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _nested_checkout_present(root: Path) -> bool:
|
|
888
|
+
"""True while a repository checkout still sits at the marker directory.
|
|
889
|
+
|
|
890
|
+
A submodule checkout is a working tree carrying a ``.git`` entry — a
|
|
891
|
+
gitdir-pointer file for an absorbed submodule, a directory otherwise, and
|
|
892
|
+
``lexists`` also counts a symlinked one. A leftover plain directory
|
|
893
|
+
without it is not another repository's working tree, so writing the
|
|
894
|
+
marker there dirties nothing but the caller's own untracked files.
|
|
895
|
+
"""
|
|
896
|
+
return os.path.lexists(root / _MARKER_DIR / ".git")
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _path_is_ignored(root: Path, relative: str) -> bool:
|
|
900
|
+
"""Ask Git whether ``relative`` is covered by an ignore rule.
|
|
901
|
+
|
|
902
|
+
``--no-index`` (as ``_require_ignored_state_root`` already passes) because
|
|
903
|
+
plain ``check-ignore`` suppresses TRACKED paths — and an already-tracked
|
|
904
|
+
marker is exactly the upgrade this change exists for. Without it the answer
|
|
905
|
+
is "not ignored" however good the rules are, and every ``init --force``
|
|
906
|
+
would append one more identical line.
|
|
907
|
+
"""
|
|
908
|
+
try:
|
|
909
|
+
proc = subprocess.run(
|
|
910
|
+
["git", "check-ignore", "-q", "--no-index", "--", relative],
|
|
911
|
+
cwd=str(root), env=isolated_git_env(), capture_output=True,
|
|
912
|
+
)
|
|
913
|
+
except OSError:
|
|
914
|
+
return False
|
|
915
|
+
return proc.returncode == 0
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _ensure_ignored(root: Path, ignore_file: Path, rule: str, probe: str) -> None:
|
|
919
|
+
"""Append ``rule`` unless it is both PRESENT here and actually in effect.
|
|
920
|
+
|
|
921
|
+
Two independent requirements, which is why neither test alone is enough.
|
|
922
|
+
|
|
923
|
+
The rule must physically be in this file, because this file is the one that
|
|
924
|
+
TRAVELS: `.git/info/exclude`, `core.excludesFile`, and a user's global
|
|
925
|
+
excludes are all machine-local, so a project whose paths are ignored only
|
|
926
|
+
through those would commit an empty `.syncmill/.gitignore` and leave the
|
|
927
|
+
next clone exposed.
|
|
928
|
+
|
|
929
|
+
And Git must actually be ignoring the path, which is asked rather than
|
|
930
|
+
inferred: a `.gitignore` can hold the rule and then negate it
|
|
931
|
+
(`!project.json`, or a blanket `!*`), where a membership test sees its own
|
|
932
|
+
rule present and adds nothing while the path stays exposed. Appending fixes
|
|
933
|
+
that too — within one file the LAST matching pattern wins — and
|
|
934
|
+
`.git/info/exclude` could not rescue it, since a per-directory `.gitignore`
|
|
935
|
+
outranks it. One append converges: the rule is then present AND last.
|
|
936
|
+
"""
|
|
937
|
+
try:
|
|
938
|
+
text = ignore_file.read_text(encoding="utf-8")
|
|
939
|
+
except FileNotFoundError:
|
|
940
|
+
text = ""
|
|
941
|
+
if rule in text.splitlines() and _path_is_ignored(root, probe):
|
|
942
|
+
return
|
|
943
|
+
with ignore_file.open("a", encoding="utf-8") as handle:
|
|
944
|
+
if text and not text.endswith("\n"):
|
|
945
|
+
handle.write("\n")
|
|
946
|
+
handle.write(f"{rule}\n")
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def initialize_project(path: Path, *, force: bool = False) -> tuple[Path, str]:
|
|
950
|
+
root = git_root(path.resolve())
|
|
951
|
+
config_path = root / "syncmill.toml"
|
|
952
|
+
marker = root / _MARKER_RELATIVE
|
|
953
|
+
if config_path.exists() and not force:
|
|
954
|
+
raise ProjectError(f"already initialized: {config_path}")
|
|
955
|
+
# Before anything is written: the marker path must be one we may write
|
|
956
|
+
# THROUGH, which is a stricter question than whether its content is trusted.
|
|
957
|
+
_reject_unwritable_marker_path(root)
|
|
958
|
+
pid: str | None = None
|
|
959
|
+
try:
|
|
960
|
+
existing = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
961
|
+
project_table = existing.get("project")
|
|
962
|
+
# A `project` key holding something other than a table has no `.get` —
|
|
963
|
+
# an AttributeError, which is neither a ValueError nor a TypeError, so it
|
|
964
|
+
# escaped this guard and crashed `init` on a malformed config.
|
|
965
|
+
candidate = project_table.get("id") if isinstance(project_table, dict) else None
|
|
966
|
+
if isinstance(candidate, str) and candidate:
|
|
967
|
+
pid = candidate
|
|
968
|
+
except (OSError, ValueError, TypeError):
|
|
969
|
+
pass
|
|
970
|
+
if pid is None:
|
|
971
|
+
# The same trust gates as every other marker read: a tracked or
|
|
972
|
+
# unsafe id is not preserved across a re-init — adopting it here would
|
|
973
|
+
# launder a shipped id into the config, where it IS trusted.
|
|
974
|
+
pid = _marker_project_id(root)
|
|
975
|
+
pid = pid or str(uuid4())
|
|
976
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
977
|
+
# `project.json` is machine-local identity (see _marker_project_id) — the
|
|
978
|
+
# ignore rule keeps a fresh project from ever committing it, since a marker
|
|
979
|
+
# that becomes tracked stops being trusted.
|
|
980
|
+
local_ignore = root / _LOCAL_IGNORE_RELATIVE
|
|
981
|
+
# PREPARE, then mutate (round 19): the config is validated and staged, and
|
|
982
|
+
# the marker fully staged as a 0600 sibling, before the first byte of any
|
|
983
|
+
# existing file changes — so every deterministic refusal (a newer
|
|
984
|
+
# config_version, a non-table key, an unwritable marker directory) exits
|
|
985
|
+
# with the tree exactly as it was. One lock span covers the config's
|
|
986
|
+
# prepare and commit (re-entrancy makes the inner acquisitions free).
|
|
987
|
+
# Residue, accepted (#99): a commit-stage OSError (the atomic write itself
|
|
988
|
+
# failing) can still leave the ignore file appended — the writes are
|
|
989
|
+
# ordered tracked-last precisely to keep that window at the two atomic
|
|
990
|
+
# replaces. A retry is unblocked only for a failure BEFORE the config is
|
|
991
|
+
# published: the refusal at the top of `initialize_project` keys on
|
|
992
|
+
# `syncmill.toml` existing, so if the config lands and the marker replace
|
|
993
|
+
# below then fails, a plain retry reports "already initialized" and needs
|
|
994
|
+
# --force. That second window is narrower (one `os.replace` of a fully
|
|
995
|
+
# staged sibling) but it is not zero. Pinned by
|
|
996
|
+
# test_failed_config_commit_leaves_the_ignore_appends_behind, which drives
|
|
997
|
+
# the first window; closing either fully would need an undo journal for the
|
|
998
|
+
# appends.
|
|
999
|
+
try:
|
|
1000
|
+
with teamfile.config_write_lock(config_path):
|
|
1001
|
+
commit_config = _prepare_project_config(config_path, pid)
|
|
1002
|
+
marker_tmp = _prepare_marker(marker, pid)
|
|
1003
|
+
try:
|
|
1004
|
+
_write_init_artifacts(
|
|
1005
|
+
root, config_path, local_ignore, commit_config
|
|
1006
|
+
)
|
|
1007
|
+
os.replace(marker_tmp, marker)
|
|
1008
|
+
except BaseException:
|
|
1009
|
+
try:
|
|
1010
|
+
os.unlink(marker_tmp)
|
|
1011
|
+
except OSError:
|
|
1012
|
+
pass
|
|
1013
|
+
raise
|
|
1014
|
+
except teamfile.TeamFileError as exc:
|
|
1015
|
+
raise ProjectError(str(exc)) from exc
|
|
1016
|
+
return config_path, pid
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _write_init_artifacts(
|
|
1020
|
+
root: Path, config_path: Path, local_ignore: Path, commit_config
|
|
1021
|
+
) -> None:
|
|
1022
|
+
"""The mutation half of ``initialize_project``, run after every refusal.
|
|
1023
|
+
|
|
1024
|
+
The config commits LAST, after both ignore writes: everything before it
|
|
1025
|
+
can still fail (an unwritable `.git/info/exclude`, round 21), and a
|
|
1026
|
+
failure with `syncmill.toml` already written turns a retry into
|
|
1027
|
+
"already initialized" over a half-built tree. The exclude append is
|
|
1028
|
+
machine-local, so its sitting mutated under a later refusal costs
|
|
1029
|
+
nothing — the reverse order cost a partial init.
|
|
1030
|
+
"""
|
|
1031
|
+
# `init` documents this file as one of its artifacts and the guides tell
|
|
1032
|
+
# the reader to commit it, so it exists even in the corner where every rule
|
|
1033
|
+
# turns out to be a no-op.
|
|
1034
|
+
local_ignore.touch(exist_ok=True)
|
|
1035
|
+
_ensure_ignored(root, local_ignore, "memory/", f"{_MARKER_DIR}/memory/")
|
|
1036
|
+
_ensure_ignored(root, local_ignore, "project.json", _MARKER_RELATIVE)
|
|
1037
|
+
_extend_info_exclude(root)
|
|
1038
|
+
commit_config()
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _extend_info_exclude(root: Path) -> None:
|
|
1042
|
+
"""Append syncmill's rules to `.git/info/exclude` (machine-local)."""
|
|
1043
|
+
exclude_probe = subprocess.run(
|
|
1044
|
+
["git", "rev-parse", "--git-path", "info/exclude"],
|
|
1045
|
+
cwd=str(root), env=isolated_git_env(), capture_output=True, text=True,
|
|
1046
|
+
)
|
|
1047
|
+
if exclude_probe.returncode == 0:
|
|
1048
|
+
exclude_path = Path(exclude_probe.stdout.strip())
|
|
1049
|
+
if not exclude_path.is_absolute():
|
|
1050
|
+
exclude_path = root / exclude_path
|
|
1051
|
+
try:
|
|
1052
|
+
existing = exclude_path.read_text(encoding="utf-8")
|
|
1053
|
+
existing_lines = existing.splitlines()
|
|
1054
|
+
rules = [
|
|
1055
|
+
r
|
|
1056
|
+
for r in (".syncmill/memory/", _MARKER_RELATIVE)
|
|
1057
|
+
if r not in existing_lines
|
|
1058
|
+
]
|
|
1059
|
+
if rules:
|
|
1060
|
+
exclude_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1061
|
+
with exclude_path.open("a", encoding="utf-8") as handle:
|
|
1062
|
+
if existing and not existing.endswith("\n"):
|
|
1063
|
+
handle.write("\n")
|
|
1064
|
+
for rule in rules:
|
|
1065
|
+
handle.write(f"{rule}\n")
|
|
1066
|
+
except OSError as exc:
|
|
1067
|
+
raise ProjectError(f"could not protect project memory in Git: {exc}") from exc
|