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.
Files changed (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. 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