agent-memory-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agent_memory/sync.py ADDED
@@ -0,0 +1,649 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Sync a memory home through plain git.
4
+
5
+ The engine owns the sync marker, the managed block of the home's
6
+ ``.gitignore``, the git state readout and the verbs ``init``, ``clone``,
7
+ ``pull``, ``push`` and ``disable``. It never reads memory content, never
8
+ merges and never touches ``keys/``.
9
+
10
+ The publication boundary
11
+ ------------------------
12
+ A memory commit holds the selected paths and nothing else. The selection is
13
+ ``git status`` limited to the layout's sync allowlist, and every candidate
14
+ must pass the layout's path predicate, which denies a never-synced name such
15
+ as ``keys/`` at any depth whatever the ignore file says; if anything selected
16
+ fails it the publish is refused. Otherwise exactly that selection is staged
17
+ and committed as a partial commit, every name taken literally (a project
18
+ called ``a*`` is a name, not a pattern), so whatever else the index holds --
19
+ a runtime file somebody staged by hand -- stays staged, uncommitted and
20
+ reported. The home must be the root of its own git worktree; a home nested
21
+ inside another repository is refused before anything is written.
22
+
23
+ ``.gitignore`` is never overwritten. The canonical allowlist is a managed
24
+ block that ``init`` puts at the head of the file when it is missing and
25
+ leaves alone when it is present, keeping every other line, and every write
26
+ comes back with a before/after receipt.
27
+
28
+ The network verbs (fetch, pull, push, clone) run under a 30 s timeout.
29
+ ``pull`` fast-forwards only when the tree is clean, not ahead, not diverged
30
+ and an upstream exists. ``push`` refuses a repository that is behind or
31
+ diverged, and a push the remote rejects leaves the local commit in place and
32
+ says so: a local-only commit and remote delivery are distinct outcomes.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import datetime as dt
38
+ import os
39
+ import shutil
40
+ import socket
41
+ import tempfile
42
+ from dataclasses import dataclass
43
+ from pathlib import Path
44
+ from typing import Iterable, List, Mapping, Optional, Sequence, Tuple
45
+
46
+ from . import layout
47
+ from .git_runner import GitResult, GitRunner, run_git
48
+
49
+ MARKER_FILE = layout.MARKER_FILE
50
+ GITIGNORE_FILE = layout.GITIGNORE_FILE
51
+ MARKER_TEXT = "agent-memory sync repository. Remove this file to disable syncing locally.\n"
52
+ NETWORK_TIMEOUT_SECONDS = 30
53
+ DEFAULT_REMOTE = "origin"
54
+
55
+ #: The agent name a commit is published under, when the caller does not pass one.
56
+ ENV_AGENT = "AGENT_MEMORY_AGENT"
57
+ _AGENT_FALLBACK_ENV = ("AGENT_NAME", "USER")
58
+ UNKNOWN_AGENT = "unknown"
59
+
60
+
61
+ class SyncError(Exception):
62
+ """A precondition failed; nothing was changed."""
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class GitState:
67
+ """Where the repository stands against its upstream, after a fetch."""
68
+
69
+ has_remote: bool
70
+ has_upstream: bool
71
+ upstream: str = ""
72
+ ahead: int = 0
73
+ behind: int = 0
74
+ dirty: bool = False
75
+ fetch_failed: bool = False
76
+ fetch_timed_out: bool = False
77
+ fetch_output: str = ""
78
+
79
+ @property
80
+ def diverged(self) -> bool:
81
+ return self.ahead > 0 and self.behind > 0
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class GitignoreReceipt:
86
+ """What :func:`ensure_gitignore` found and what it left behind."""
87
+
88
+ path: Path
89
+ #: ``created``, ``unchanged`` or ``updated``.
90
+ action: str
91
+ before: Optional[str]
92
+ after: str
93
+
94
+ @property
95
+ def changed(self) -> bool:
96
+ return self.before != self.after
97
+
98
+ def lines(self) -> List[str]:
99
+ if not self.changed:
100
+ return [f"{GITIGNORE_FILE}: managed block present; left unchanged."]
101
+ if self.before is None:
102
+ return [f"{GITIGNORE_FILE}: created with the managed block."]
103
+ kept = self.after[len(layout.gitignore_text()) :]
104
+ out = [
105
+ f"{GITIGNORE_FILE}: managed block added at the top; "
106
+ f"{len(kept.splitlines())} existing line(s) kept after it.",
107
+ f"{GITIGNORE_FILE} before:",
108
+ *_indent(self.before.splitlines() or ["(empty)"]),
109
+ f"{GITIGNORE_FILE} after:",
110
+ *_indent(self.after.splitlines()),
111
+ ]
112
+ return out
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Outcome:
117
+ """The result of a verb: a distinct status, whether it counts as success, and what to say."""
118
+
119
+ status: str
120
+ ok: bool
121
+ lines: Tuple[str, ...] = ()
122
+ #: Paths the verb committed, home-relative.
123
+ committed: Tuple[str, ...] = ()
124
+ #: Paths that were staged outside the selection and were left exactly as found.
125
+ preserved: Tuple[str, ...] = ()
126
+ receipt: Optional[GitignoreReceipt] = None
127
+
128
+
129
+ # --- marker and ignore file -------------------------------------------------
130
+
131
+
132
+ def marker_path(home: Path) -> Path:
133
+ return home / MARKER_FILE
134
+
135
+
136
+ def is_configured(home: Path) -> bool:
137
+ return marker_path(home).is_file()
138
+
139
+
140
+ def write_marker(home: Path) -> bool:
141
+ """Create the marker if it is missing; an existing marker keeps its bytes."""
142
+ path = marker_path(home)
143
+ if path.is_file():
144
+ return False
145
+ path.write_text(MARKER_TEXT, encoding="utf-8")
146
+ return True
147
+
148
+
149
+ def ensure_gitignore(home: Path) -> GitignoreReceipt:
150
+ """Put the managed block at the head of ``.gitignore`` unless it is already there.
151
+
152
+ The block is the canonical allowlist from :func:`layout.gitignore_text`.
153
+ A missing file is created with the block alone, so a fresh home's file is
154
+ byte-identical to the canonical text. A file that already contains the
155
+ block, contiguous and on line boundaries, is left untouched wherever the
156
+ block sits. Otherwise the block goes first and every line of the existing
157
+ file that is not itself a block line follows it verbatim: custom rules
158
+ survive, and a file carrying an older version of the block is brought up
159
+ to date without duplicating its lines.
160
+ """
161
+ path = home / GITIGNORE_FILE
162
+ block = layout.gitignore_text()
163
+ try:
164
+ before: Optional[str] = path.read_text(encoding="utf-8")
165
+ except FileNotFoundError:
166
+ before = None
167
+ if before is None:
168
+ after, action = block, "created"
169
+ elif _contains_block(before, block):
170
+ after, action = before, "unchanged"
171
+ else:
172
+ managed = set(block.splitlines())
173
+ kept = [line for line in before.replace("\r\n", "\n").splitlines() if line not in managed]
174
+ after = block + ("\n".join(kept) + "\n" if kept else "")
175
+ action = "updated"
176
+ if after != before:
177
+ path.write_text(after, encoding="utf-8")
178
+ return GitignoreReceipt(path, action, before, after)
179
+
180
+
181
+ def _contains_block(text: str, block: str) -> bool:
182
+ normalized = text.replace("\r\n", "\n")
183
+ return normalized.startswith(block) or f"\n{block}" in normalized
184
+
185
+
186
+ def gitignore_has_managed_block(text: str) -> bool:
187
+ """Whether ``text`` carries the managed allowlist block, contiguous and on line boundaries."""
188
+ return _contains_block(text, layout.gitignore_text())
189
+
190
+
191
+ # --- git readout ------------------------------------------------------------
192
+
193
+
194
+ def is_git_repo(home: Path, runner: Optional[GitRunner] = None) -> bool:
195
+ return _git(home, ["rev-parse", "--is-inside-work-tree"], runner).ok
196
+
197
+
198
+ def worktree_root(home: Path, runner: Optional[GitRunner] = None) -> Optional[Path]:
199
+ """The root of the worktree ``home`` sits in, or ``None`` outside any repository."""
200
+ result = _git(home, ["rev-parse", "--show-toplevel"], runner)
201
+ if not result.ok or not result.stdout.strip():
202
+ return None
203
+ return Path(result.stdout.strip())
204
+
205
+
206
+ def git_state(home: Path, *, runner: Optional[GitRunner] = None, fetch: bool = True) -> GitState:
207
+ dirty = bool(_status_porcelain(home, runner))
208
+ remote = _has_remote(home, runner)
209
+ fetch_failed = fetch_timed_out = False
210
+ fetch_output = ""
211
+ if fetch and remote:
212
+ fetched = _git(home, ["fetch", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
213
+ fetch_failed = not fetched.ok
214
+ fetch_timed_out = fetched.timed_out
215
+ fetch_output = fetched.output
216
+ upstream = _upstream(home, runner)
217
+ ahead = behind = 0
218
+ if upstream:
219
+ counted = _git(home, ["rev-list", "--left-right", "--count", f"HEAD...{upstream}"], runner)
220
+ parts = counted.stdout.split()
221
+ if counted.ok and len(parts) >= 2:
222
+ ahead, behind = int(parts[0]), int(parts[1])
223
+ else:
224
+ fetch_failed, fetch_output = True, counted.output
225
+ return GitState(
226
+ has_remote=remote,
227
+ has_upstream=bool(upstream),
228
+ upstream=upstream,
229
+ ahead=ahead,
230
+ behind=behind,
231
+ dirty=dirty,
232
+ fetch_failed=fetch_failed,
233
+ fetch_timed_out=fetch_timed_out,
234
+ fetch_output=fetch_output,
235
+ )
236
+
237
+
238
+ # --- the verbs --------------------------------------------------------------
239
+
240
+
241
+ def init(
242
+ home: Path,
243
+ *,
244
+ remote: Optional[str] = None,
245
+ agent: Optional[str] = None,
246
+ runner: Optional[GitRunner] = None,
247
+ env: Optional[Mapping[str, str]] = None,
248
+ ) -> Outcome:
249
+ """Make ``home`` a memory repository: git, the managed ignore block, the marker, one commit.
250
+
251
+ Rerunning on an initialized home changes nothing it does not have to:
252
+ the ignore block is only added when missing, the marker keeps its bytes,
253
+ and the commit covers only what the allowlist selects.
254
+ """
255
+ home = _home(home)
256
+ home.mkdir(parents=True, exist_ok=True)
257
+ if is_git_repo(home, runner):
258
+ _require_root(home, runner)
259
+ else:
260
+ result = _git(home, ["init", "--quiet"], runner)
261
+ if not result.ok:
262
+ raise SyncError(f"git init failed: {result.output}")
263
+ receipt = ensure_gitignore(home)
264
+ lines = receipt.lines()
265
+ if write_marker(home):
266
+ lines.append(f"{MARKER_FILE}: created.")
267
+ if remote:
268
+ verb = "set-url" if _git(home, ["remote", "get-url", DEFAULT_REMOTE], runner).ok else "add"
269
+ result = _git(home, ["remote", verb, DEFAULT_REMOTE, remote], runner)
270
+ if not result.ok:
271
+ raise SyncError(f"git remote {verb} failed: {result.output}")
272
+ lines.append(f"remote {DEFAULT_REMOTE}: {remote}")
273
+
274
+ published = _publish(home, agent=agent, runner=runner, env=env)
275
+ lines.extend(published.lines)
276
+ if not published.ok:
277
+ return _with(published, lines=lines, receipt=receipt)
278
+ if not remote:
279
+ return Outcome("local_only", True, tuple(lines), published.committed, published.preserved, receipt)
280
+ return _deliver(home, published, git_state(home, runner=runner, fetch=False), lines, runner, receipt)
281
+
282
+
283
+ def clone(home: Path, url: str, *, force: bool = False, runner: Optional[GitRunner] = None) -> Outcome:
284
+ """Clone a memory repository into ``home``; a non-empty ``home`` is refused unless ``force``.
285
+
286
+ With ``force`` the existing directory is moved aside to a timestamped
287
+ sibling first and moved back if the clone fails.
288
+ """
289
+ home = _home(home)
290
+ if not _is_non_empty(home):
291
+ home.parent.mkdir(parents=True, exist_ok=True)
292
+ result = _git(home.parent, ["clone", url, str(home)], runner, timeout=NETWORK_TIMEOUT_SECONDS)
293
+ if not result.ok:
294
+ raise SyncError(f"git clone failed: {result.output}")
295
+ return Outcome("cloned", True, (f"Cloned the memory repository into {home}.",))
296
+ if not force:
297
+ raise SyncError(f"refusing to clone into a non-empty home: {home}; pass --force to move it aside first")
298
+ stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d%H%M%S")
299
+ backup = home.with_name(f"{home.name}.backup-{stamp}")
300
+ shutil.move(str(home), str(backup))
301
+ try:
302
+ result = _git(home.parent, ["clone", url, str(home)], runner, timeout=NETWORK_TIMEOUT_SECONDS)
303
+ except Exception:
304
+ if not home.exists():
305
+ shutil.move(str(backup), str(home))
306
+ raise
307
+ if not result.ok:
308
+ if home.exists():
309
+ shutil.rmtree(home)
310
+ shutil.move(str(backup), str(home))
311
+ raise SyncError(f"git clone failed: {result.output}")
312
+ return Outcome(
313
+ "cloned",
314
+ True,
315
+ (f"Moved the existing home aside to {backup}.", f"Cloned the memory repository into {home}."),
316
+ )
317
+
318
+
319
+ def pull(home: Path, *, runner: Optional[GitRunner] = None) -> Outcome:
320
+ """Fast-forward ``home`` from its upstream when that is the only thing that could happen.
321
+
322
+ Silent on a home that is not configured for sync. Every state that rules
323
+ a fast-forward out (a failed fetch, a dirty tree, divergence) is its own
324
+ status and not a success; being ahead or having no upstream is reported
325
+ and is not an error.
326
+ """
327
+ home = _home(home)
328
+ if not is_configured(home):
329
+ return Outcome("not_configured", True)
330
+ _require_repo(home, runner)
331
+ state = git_state(home, runner=runner, fetch=True)
332
+ if not state.has_remote:
333
+ return Outcome("local_only", True, ("memory pull: local-only repository; no remote to pull from.",))
334
+ if state.fetch_failed:
335
+ return _fetch_failure(state, "memory pull")
336
+ if state.dirty:
337
+ return Outcome("dirty", False, ("memory pull: uncommitted changes present; not pulling.",))
338
+ if state.diverged:
339
+ return Outcome("diverged", False, ("memory pull: diverged from upstream; resolve manually.",))
340
+ if not state.has_upstream:
341
+ return Outcome("no_upstream", True, ("memory pull: no upstream branch configured; skipping.",))
342
+ if state.ahead:
343
+ return Outcome("ahead", True, (f"memory pull: {state.ahead} unpushed commit(s); nothing to pull.",))
344
+ if not state.behind:
345
+ return Outcome("up_to_date", True, ("memory pull: already synced.",))
346
+ result = _git(home, ["pull", "--ff-only", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
347
+ if result.timed_out:
348
+ return Outcome("pull_timed_out", False, (f"memory pull: {result.output}",))
349
+ if not result.ok:
350
+ return Outcome("pull_failed", False, (f"memory pull: fast-forward failed: {result.output}",))
351
+ return Outcome("synced", True, (f"memory pull: synced {state.behind} commit(s).",))
352
+
353
+
354
+ def push(
355
+ home: Path,
356
+ *,
357
+ agent: Optional[str] = None,
358
+ runner: Optional[GitRunner] = None,
359
+ env: Optional[Mapping[str, str]] = None,
360
+ ) -> Outcome:
361
+ """Commit the allowlisted memory changes and deliver them when a remote exists.
362
+
363
+ Silent on a home that is not configured for sync. A home that is behind
364
+ or diverged is refused before anything is committed. A commit that could
365
+ not be delivered -- no remote, an unreachable one, a rejected push -- is
366
+ reported as such, distinctly from delivery.
367
+ """
368
+ home = _home(home)
369
+ if not is_configured(home):
370
+ return Outcome("not_configured", True)
371
+ _require_repo(home, runner)
372
+ state = git_state(home, runner=runner, fetch=True)
373
+ if state.has_remote and not state.fetch_failed:
374
+ if state.diverged:
375
+ return Outcome("diverged", False, ("memory push: diverged from upstream; resolve manually before pushing.",))
376
+ if state.behind:
377
+ return Outcome(
378
+ "behind", False, (f"memory push: behind upstream by {state.behind} commit(s); pull before pushing.",)
379
+ )
380
+ published = _publish(home, agent=agent, runner=runner, env=env)
381
+ if not published.ok:
382
+ return published
383
+ lines = list(published.lines)
384
+ if not state.has_remote:
385
+ lines.append("memory push: no remote configured; the commit remains local.")
386
+ return Outcome("local_only", True, tuple(lines), published.committed, published.preserved)
387
+ return _deliver(home, published, state, lines, runner)
388
+
389
+
390
+ def disable(home: Path) -> Outcome:
391
+ """Remove the marker; the repository and its history stay in place."""
392
+ home = _home(home)
393
+ path = marker_path(home)
394
+ if path.exists():
395
+ path.unlink()
396
+ return Outcome("disabled", True, (f"Removed {path}; syncing is disabled for this home.",))
397
+ return Outcome("already_disabled", True, ("Syncing is already disabled for this home.",))
398
+
399
+
400
+ # --- the publication boundary -----------------------------------------------
401
+
402
+
403
+ def select_paths(home: Path, runner: Optional[GitRunner] = None) -> Tuple[List[str], List[str]]:
404
+ """Split what ``git status`` reports inside the allowlisted tiers into (selected, outside).
405
+
406
+ The query is limited to the layout's tier patterns plus the ignore file
407
+ and the marker, so nothing elsewhere in the tree is ever a candidate; and
408
+ every candidate is checked against :func:`layout.is_allowed_memory_path`,
409
+ so a widened ignore file cannot smuggle a tier's unsynced subdirectory in.
410
+ """
411
+ pathspecs = [GITIGNORE_FILE, MARKER_FILE, *(f":(glob){tier.pattern}/**" for tier in layout.TIERS)]
412
+ result = _git(home, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", *pathspecs], runner)
413
+ if not result.ok:
414
+ raise SyncError(f"git status failed: {result.output}")
415
+ paths = _parse_status_z(result.stdout)
416
+ selected = sorted(path for path in paths if layout.is_allowed_memory_path(path))
417
+ outside = sorted(path for path in paths if not layout.is_allowed_memory_path(path))
418
+ return selected, outside
419
+
420
+
421
+ def staged_paths(home: Path, runner: Optional[GitRunner] = None) -> List[str]:
422
+ """Every path the index differs in from HEAD (or from the empty tree before the first commit)."""
423
+ result = _git(home, ["diff", "--cached", "--name-only", "-z"], runner)
424
+ if not result.ok:
425
+ raise SyncError(f"git diff --cached failed: {result.output}")
426
+ return [path for path in result.stdout.split("\0") if path]
427
+
428
+
429
+ def committed_paths(home: Path, runner: Optional[GitRunner] = None) -> List[str]:
430
+ """The paths HEAD's commit touched."""
431
+ result = _git(home, ["show", "--format=", "--name-only", "-z", "HEAD"], runner)
432
+ if not result.ok:
433
+ raise SyncError(f"git show failed: {result.output}")
434
+ return [path for path in result.stdout.split("\0") if path]
435
+
436
+
437
+ def resolve_agent(explicit: Optional[str] = None, env: Optional[Mapping[str, str]] = None) -> str:
438
+ """The name a commit is published under: the caller's, else the environment's, else ``unknown``."""
439
+ if explicit:
440
+ return explicit
441
+ source: Mapping[str, str] = os.environ if env is None else env
442
+ for name in (ENV_AGENT, *_AGENT_FALLBACK_ENV):
443
+ value = source.get(name)
444
+ if value:
445
+ return value
446
+ return UNKNOWN_AGENT
447
+
448
+
449
+ def commit_message(file_count: int, agent: str, *, now: Optional[dt.datetime] = None) -> str:
450
+ host = socket.gethostname().split(".")[0] or "host"
451
+ today = (now or dt.datetime.now(dt.timezone.utc)).strftime("%Y-%m-%d")
452
+ return f"memory: {agent}@{host} {today} ({file_count} files)"
453
+
454
+
455
+ def _publish(
456
+ home: Path,
457
+ *,
458
+ agent: Optional[str],
459
+ runner: Optional[GitRunner],
460
+ env: Optional[Mapping[str, str]],
461
+ ) -> Outcome:
462
+ selected, outside = select_paths(home, runner)
463
+ if outside:
464
+ return Outcome(
465
+ "outside_allowlist",
466
+ False,
467
+ (
468
+ f"memory publish: refusing to commit; {len(outside)} path(s) inside a memory tier fall outside "
469
+ f"the sync allowlist (a never-synced name such as keys/, or a widened {GITIGNORE_FILE}): "
470
+ f"{', '.join(outside)}",
471
+ ),
472
+ )
473
+ preserved = tuple(sorted(set(staged_paths(home, runner)) - set(selected)))
474
+ lines: List[str] = []
475
+ if preserved:
476
+ lines.append(
477
+ f"memory publish: {len(preserved)} staged path(s) outside the allowlist left staged and "
478
+ f"uncommitted: {', '.join(preserved)}"
479
+ )
480
+ if not selected:
481
+ lines.append("memory publish: no memory changes to commit.")
482
+ return Outcome("nothing_to_commit", True, tuple(lines), (), preserved)
483
+
484
+ message = commit_message(len(selected), resolve_agent(agent, env))
485
+ with _pathspec_file(selected) as pathspec:
486
+ added = _git(home, ["--literal-pathspecs", "add", *_from_file(pathspec)], runner)
487
+ if not added.ok:
488
+ return Outcome("commit_failed", False, (*lines, f"memory publish: git add failed: {added.output}"))
489
+ committed = _git(home, ["--literal-pathspecs", "commit", "--quiet", "-m", message, *_from_file(pathspec)], runner)
490
+ if not committed.ok:
491
+ return Outcome("commit_failed", False, (*lines, f"memory publish: git commit failed: {committed.output}"))
492
+ published = committed_paths(home, runner)
493
+ stray = sorted(path for path in published if not layout.is_allowed_memory_path(path))
494
+ if stray:
495
+ raise SyncError(f"memory publish: HEAD commits paths outside the allowlist: {', '.join(stray)}")
496
+ lines.append(f"memory publish: committed {len(published)} file(s).")
497
+ return Outcome("committed", True, tuple(lines), tuple(published), preserved)
498
+
499
+
500
+ def _deliver(
501
+ home: Path,
502
+ published: Outcome,
503
+ state: GitState,
504
+ lines: List[str],
505
+ runner: Optional[GitRunner],
506
+ receipt: Optional[GitignoreReceipt] = None,
507
+ ) -> Outcome:
508
+ if state.fetch_failed:
509
+ failure = _fetch_failure(state, "memory push")
510
+ lines.append(f"{failure.lines[0]} The commit remains local.")
511
+ return Outcome(failure.status, False, tuple(lines), published.committed, published.preserved, receipt)
512
+ if not published.committed and state.has_upstream and not state.ahead:
513
+ lines.append("memory push: remote already up to date.")
514
+ return Outcome("up_to_date", True, tuple(lines), (), published.preserved, receipt)
515
+ result = _push_remote(home, state, runner)
516
+ if not result.ok:
517
+ what = "timed out" if result.timed_out else "was rejected"
518
+ lines.append(f"memory push: the push {what}; the commit remains local. {result.output}".rstrip())
519
+ status = "push_timed_out" if result.timed_out else "push_failed"
520
+ return Outcome(status, False, tuple(lines), published.committed, published.preserved, receipt)
521
+ lines.append("memory push: delivered to the remote.")
522
+ return Outcome("pushed", True, tuple(lines), published.committed, published.preserved, receipt)
523
+
524
+
525
+ def _push_remote(home: Path, state: GitState, runner: Optional[GitRunner]) -> GitResult:
526
+ if state.has_upstream:
527
+ return _git(home, ["push", "--quiet"], runner, timeout=NETWORK_TIMEOUT_SECONDS)
528
+ remote = _default_remote(home, runner)
529
+ branch = _current_branch(home, runner)
530
+ return _git(home, ["push", "--quiet", "-u", remote, branch], runner, timeout=NETWORK_TIMEOUT_SECONDS)
531
+
532
+
533
+ def _fetch_failure(state: GitState, verb: str) -> Outcome:
534
+ if state.fetch_timed_out:
535
+ return Outcome("fetch_timed_out", False, (f"{verb}: the fetch timed out; {state.fetch_output}".rstrip(),))
536
+ return Outcome("fetch_failed", False, (f"{verb}: the fetch failed; {state.fetch_output}".rstrip(),))
537
+
538
+
539
+ # --- helpers ----------------------------------------------------------------
540
+
541
+
542
+ def _git(
543
+ cwd: Path, args: Sequence[str], runner: Optional[GitRunner], *, timeout: Optional[float] = None
544
+ ) -> GitResult:
545
+ return (runner or run_git)(args, cwd=cwd, timeout=timeout)
546
+
547
+
548
+ def _home(value: Path) -> Path:
549
+ return Path(value).expanduser().absolute()
550
+
551
+
552
+ def _require_repo(home: Path, runner: Optional[GitRunner]) -> None:
553
+ if not is_git_repo(home, runner):
554
+ raise SyncError(f"{MARKER_FILE} is present, but {home} is not a git repository")
555
+ _require_root(home, runner)
556
+
557
+
558
+ def _require_root(home: Path, runner: Optional[GitRunner]) -> None:
559
+ root = worktree_root(home, runner)
560
+ if root is None:
561
+ raise SyncError(f"{home} is not inside a git worktree")
562
+ if root.resolve() != home.resolve():
563
+ raise SyncError(
564
+ f"root mismatch: {home} is inside the git worktree {root}; "
565
+ "a memory home must be the root of its own repository"
566
+ )
567
+
568
+
569
+ def _status_porcelain(home: Path, runner: Optional[GitRunner]) -> str:
570
+ result = _git(home, ["status", "--porcelain"], runner)
571
+ if not result.ok:
572
+ raise SyncError(f"git status failed: {result.output}")
573
+ return result.stdout.strip()
574
+
575
+
576
+ def _has_remote(home: Path, runner: Optional[GitRunner]) -> bool:
577
+ result = _git(home, ["remote"], runner)
578
+ return result.ok and bool(result.stdout.strip())
579
+
580
+
581
+ def _default_remote(home: Path, runner: Optional[GitRunner]) -> str:
582
+ result = _git(home, ["remote"], runner)
583
+ remotes = [line.strip() for line in result.stdout.splitlines() if line.strip()] if result.ok else []
584
+ return remotes[0] if remotes else DEFAULT_REMOTE
585
+
586
+
587
+ def _upstream(home: Path, runner: Optional[GitRunner]) -> str:
588
+ result = _git(home, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], runner)
589
+ return result.stdout.strip() if result.ok else ""
590
+
591
+
592
+ def _current_branch(home: Path, runner: Optional[GitRunner]) -> str:
593
+ result = _git(home, ["branch", "--show-current"], runner)
594
+ return result.stdout.strip() if result.ok and result.stdout.strip() else "HEAD"
595
+
596
+
597
+ def _is_non_empty(path: Path) -> bool:
598
+ return path.exists() and any(path.iterdir())
599
+
600
+
601
+ def _parse_status_z(data: str) -> List[str]:
602
+ """Paths from ``git status --porcelain=v1 -z``; a rename or copy contributes both of its paths."""
603
+ paths: List[str] = []
604
+ fields = data.split("\0")
605
+ index = 0
606
+ while index < len(fields):
607
+ entry = fields[index]
608
+ index += 1
609
+ if len(entry) < 4:
610
+ continue
611
+ paths.append(entry[3:])
612
+ if entry[0] in "RC" and index < len(fields) and fields[index]:
613
+ paths.append(fields[index])
614
+ index += 1
615
+ return paths
616
+
617
+
618
+ def _from_file(pathspec: Path) -> List[str]:
619
+ return [f"--pathspec-from-file={pathspec}", "--pathspec-file-nul"]
620
+
621
+
622
+ class _pathspec_file:
623
+ """A NUL-separated pathspec file for ``--pathspec-from-file``, removed on exit."""
624
+
625
+ def __init__(self, paths: Iterable[str]) -> None:
626
+ self._paths = list(paths)
627
+ self._path: Optional[Path] = None
628
+
629
+ def __enter__(self) -> Path:
630
+ handle = tempfile.NamedTemporaryFile("wb", prefix="agent-memory-pathspec-", delete=False)
631
+ with handle:
632
+ handle.write("\0".join(self._paths).encode("utf-8", "surrogateescape"))
633
+ self._path = Path(handle.name)
634
+ return self._path
635
+
636
+ def __exit__(self, *_: object) -> None:
637
+ if self._path is not None:
638
+ try:
639
+ self._path.unlink()
640
+ except OSError:
641
+ pass
642
+
643
+
644
+ def _with(outcome: Outcome, *, lines: Sequence[str], receipt: Optional[GitignoreReceipt]) -> Outcome:
645
+ return Outcome(outcome.status, outcome.ok, tuple(lines), outcome.committed, outcome.preserved, receipt)
646
+
647
+
648
+ def _indent(lines: Iterable[str]) -> List[str]:
649
+ return [f" {line}" for line in lines]
@@ -0,0 +1,5 @@
1
+ # Org Decisions
2
+
3
+ <!-- Org-wide technical choices, conventions, and architectural calls. -->
4
+ <!-- Curated by coordinator from events/ — patterns repeating 3+ times get promoted here. -->
5
+ <!-- This is an illustrative default topical file. Adopters may rename, remove, or add others. -->
@@ -0,0 +1,16 @@
1
+ # Org Memory — Rolling Summary
2
+
3
+ <!-- Always-loaded context for agents. Keep under ~150 lines. -->
4
+ <!-- Updated by coordinator during sync/curation. -->
5
+
6
+ ## Current State
7
+
8
+ <!-- High-level summary of what's happening across the org. -->
9
+
10
+ ## Active Decisions
11
+
12
+ <!-- Recent decisions that agents should be aware of. -->
13
+
14
+ ## Standing Rules
15
+
16
+ <!-- Conventions and rules currently in effect. -->
@@ -0,0 +1,5 @@
1
+ # Org Rules
2
+
3
+ <!-- Standing conventions: naming, API patterns, timezone, style, etc. -->
4
+ <!-- Curated by coordinator from events/ — patterns repeating 3+ times get promoted here. -->
5
+ <!-- This is an illustrative default topical file. Adopters may rename, remove, or add others. -->
@@ -0,0 +1,3 @@
1
+ # Decision Log
2
+
3
+ <!-- One dated entry per decision, newest first: what was decided, and why. -->