memgit 0.10.0__tar.gz → 0.11.0__tar.gz

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 (67) hide show
  1. {memgit-0.10.0 → memgit-0.11.0}/PKG-INFO +61 -2
  2. {memgit-0.10.0 → memgit-0.11.0}/README.md +59 -0
  3. {memgit-0.10.0 → memgit-0.11.0}/memgit/__init__.py +1 -1
  4. {memgit-0.10.0 → memgit-0.11.0}/memgit/cli.py +15 -1
  5. {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/commands.py +42 -3
  6. memgit-0.11.0/memgit/env.py +88 -0
  7. {memgit-0.10.0 → memgit-0.11.0}/memgit/mcp_server.py +1 -1
  8. {memgit-0.10.0 → memgit-0.11.0}/memgit/repo.py +175 -16
  9. {memgit-0.10.0 → memgit-0.11.0}/memgit/savings.py +21 -2
  10. {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/PKG-INFO +61 -2
  11. {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/SOURCES.txt +8 -1
  12. {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/requires.txt +1 -1
  13. {memgit-0.10.0 → memgit-0.11.0}/pyproject.toml +23 -9
  14. memgit-0.11.0/tests/test_cloud_guard.py +66 -0
  15. memgit-0.11.0/tests/test_dependency_floor.py +78 -0
  16. memgit-0.11.0/tests/test_env_inventory.py +86 -0
  17. memgit-0.11.0/tests/test_git_args.py +71 -0
  18. memgit-0.11.0/tests/test_lock_exclusion.py +87 -0
  19. {memgit-0.10.0 → memgit-0.11.0}/tests/test_savings.py +31 -0
  20. {memgit-0.10.0 → memgit-0.11.0}/tests/test_server_lifecycle.py +11 -1
  21. memgit-0.11.0/tests/test_version_stamps.py +83 -0
  22. {memgit-0.10.0 → memgit-0.11.0}/LICENSE +0 -0
  23. {memgit-0.10.0 → memgit-0.11.0}/memgit/backup.py +0 -0
  24. {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/__init__.py +0 -0
  25. {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/client.py +0 -0
  26. {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/crypto.py +0 -0
  27. {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/state.py +0 -0
  28. {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/sync.py +0 -0
  29. {memgit-0.10.0 → memgit-0.11.0}/memgit/delivery.py +0 -0
  30. {memgit-0.10.0 → memgit-0.11.0}/memgit/evaluate.py +0 -0
  31. {memgit-0.10.0 → memgit-0.11.0}/memgit/gitdigest.py +0 -0
  32. {memgit-0.10.0 → memgit-0.11.0}/memgit/graph.py +0 -0
  33. {memgit-0.10.0 → memgit-0.11.0}/memgit/hooks.py +0 -0
  34. {memgit-0.10.0 → memgit-0.11.0}/memgit/http_server.py +0 -0
  35. {memgit-0.10.0 → memgit-0.11.0}/memgit/importer.py +0 -0
  36. {memgit-0.10.0 → memgit-0.11.0}/memgit/license.py +0 -0
  37. {memgit-0.10.0 → memgit-0.11.0}/memgit/links.py +0 -0
  38. {memgit-0.10.0 → memgit-0.11.0}/memgit/metrics.py +0 -0
  39. {memgit-0.10.0 → memgit-0.11.0}/memgit/models.py +0 -0
  40. {memgit-0.10.0 → memgit-0.11.0}/memgit/project.py +0 -0
  41. {memgit-0.10.0 → memgit-0.11.0}/memgit/sanitize.py +0 -0
  42. {memgit-0.10.0 → memgit-0.11.0}/memgit/scorer.py +0 -0
  43. {memgit-0.10.0 → memgit-0.11.0}/memgit/store.py +0 -0
  44. {memgit-0.10.0 → memgit-0.11.0}/memgit/tokens.py +0 -0
  45. {memgit-0.10.0 → memgit-0.11.0}/memgit/toon.py +0 -0
  46. {memgit-0.10.0 → memgit-0.11.0}/memgit/usage.py +0 -0
  47. {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/dependency_links.txt +0 -0
  48. {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/entry_points.txt +0 -0
  49. {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/top_level.txt +0 -0
  50. {memgit-0.10.0 → memgit-0.11.0}/setup.cfg +0 -0
  51. {memgit-0.10.0 → memgit-0.11.0}/tests/test_accrue.py +0 -0
  52. {memgit-0.10.0 → memgit-0.11.0}/tests/test_advanced.py +0 -0
  53. {memgit-0.10.0 → memgit-0.11.0}/tests/test_aliases.py +0 -0
  54. {memgit-0.10.0 → memgit-0.11.0}/tests/test_backup.py +0 -0
  55. {memgit-0.10.0 → memgit-0.11.0}/tests/test_core.py +0 -0
  56. {memgit-0.10.0 → memgit-0.11.0}/tests/test_delivery.py +0 -0
  57. {memgit-0.10.0 → memgit-0.11.0}/tests/test_license.py +0 -0
  58. {memgit-0.10.0 → memgit-0.11.0}/tests/test_setup.py +0 -0
  59. {memgit-0.10.0 → memgit-0.11.0}/tests/test_store_repo.py +0 -0
  60. {memgit-0.10.0 → memgit-0.11.0}/tests/test_toon.py +0 -0
  61. {memgit-0.10.0 → memgit-0.11.0}/tests/test_v020.py +0 -0
  62. {memgit-0.10.0 → memgit-0.11.0}/tests/test_v030.py +0 -0
  63. {memgit-0.10.0 → memgit-0.11.0}/tests/test_v040.py +0 -0
  64. {memgit-0.10.0 → memgit-0.11.0}/tests/test_v060.py +0 -0
  65. {memgit-0.10.0 → memgit-0.11.0}/tests/test_v070.py +0 -0
  66. {memgit-0.10.0 → memgit-0.11.0}/tests/test_v080.py +0 -0
  67. {memgit-0.10.0 → memgit-0.11.0}/tests/test_v081.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgit
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: Git for AI memory — version-controlled context persistence across Claude, GPT, Gemini, Cursor, Windsurf, and more
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://memgit.dev
@@ -21,7 +21,7 @@ Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: click>=8.0
23
23
  Requires-Dist: rich>=13.0
24
- Requires-Dist: mcp<2,>=1.0.0
24
+ Requires-Dist: mcp<2,>=1.28.1
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: pytest>=8.0; extra == "dev"
27
27
  Requires-Dist: pytest-anyio>=0.0.0; extra == "dev"
@@ -475,6 +475,65 @@ memgit thread list / switch / create
475
475
 
476
476
  ---
477
477
 
478
+ ## What memgit reads from your environment
479
+
480
+ memgit runs inside your agent, so the honest answer to "what does this process
481
+ read from my environment?" should be checkable rather than a promise. The table
482
+ below is generated from [`memgit/env.py`](memgit/env.py), and
483
+ `tests/test_env_inventory.py` walks the package on every test run and fails if
484
+ the code reads a name this list does not carry — or carries a name nothing
485
+ reads. The list cannot drift away from the code.
486
+
487
+ memgit reads these and nothing else. It writes none of them. None of them turn
488
+ network access on: the cloud endpoints are overrides for a sync you have already
489
+ opted into with `memgit cloud login`, and with no licence and no cloud login,
490
+ memgit talks to nothing.
491
+
492
+ | | Variable | What memgit does with it |
493
+ |---|---|---|
494
+ | Store and state | `MEMGIT_STORE` | Absolute path to the memory store. When set it is the ONLY candidate — an explicit store never silently falls back to another one. |
495
+ | | `MEMGIT_HOME` | Base directory for memgit's per-person state, currently the licence file (default ~/.memgit, mode 0600). |
496
+ | | `MEMGIT_PROJECT` | Forces the project label instead of detecting it from the working directory. |
497
+ | Attribution | `MEMGIT_AUTHOR` | Explicit author stamped on checkpoints, for multi-agent jobs where several writers share one machine account. Wins over MEMGIT_CLIENT. |
498
+ | | `MEMGIT_CLIENT` | The host that launched this process (claude-code, cursor, ...), stamped into checkpoints so a memory says where it came from. |
499
+ | | `USER` | Fallback author name when neither MEMGIT_AUTHOR nor MEMGIT_CLIENT is set. |
500
+ | | `USERNAME` | Windows fallback for USER. |
501
+ | Licensing (memgit Pro) | `MEMGIT_LICENSE_KEY` | memgit Pro licence key. Never printed in full — `memgit pro status` shows the last four characters. |
502
+ | | `MEMGIT_POLAR_API` | Overrides the licence-validation API base URL (testing). |
503
+ | | `MEMGIT_POLAR_ORG_ID` | Overrides the Polar organisation the licence is checked against. |
504
+ | Cloud sync | `MEMGIT_CLOUD_API` | Overrides the memgit cloud API base URL. |
505
+ | | `MEMGIT_CLOUD_APP` | Overrides the memgit cloud web app base URL used in printed links. |
506
+ | | `MEMGIT_CLOUD_NO_CACHE` | Set to 1/true/yes to keep decrypted keys out of credentials.json; the passphrase is then re-prompted per command. |
507
+ | Tuning | `MEMGIT_LOCK_TIMEOUT` | Seconds a writer waits for the store lock before giving up (default 10). |
508
+ | | `MEMGIT_IDLE_EVICT_SECONDS` | Idle seconds before the MCP server drops its in-memory caches while staying connected (default 900). |
509
+ | | `MEMGIT_HOUSEKEEPING_INTERVAL` | Seconds between the MCP server's housekeeping passes. |
510
+ | Set by other software | `CLAUDE_PROJECT_DIR` | Set by Claude Code for hook processes; used as one input to project detection when the working directory is not the project root. |
511
+ | | `PYTEST_CURRENT_TEST` | Set by pytest. Its presence suppresses automatic backups, so a test run never writes into a real backup location. |
512
+
513
+ ### How the project label is decided
514
+
515
+ Memories are project-scoped, so a wrong label files a memory where you will not
516
+ find it again. The label comes from the first of these that yields one, and
517
+ there is exactly one detection path shared by the MCP server, the CLI and the
518
+ hooks — so a label derived at save time and one derived at recall time cannot
519
+ disagree:
520
+
521
+ 1. `MEMGIT_PROJECT` — a forced label, taken verbatim
522
+ 2. the `cwd` a host reports in its hook payload (the real workspace, even when
523
+ the process working directory is elsewhere)
524
+ 3. `CLAUDE_PROJECT_DIR`
525
+ 4. the process working directory
526
+
527
+ When none of them yields a label — running from `$HOME`, for instance — a write
528
+ is **quarantined** under `_unknown` rather than filed globally, and
529
+ `memgit doctor --relabel` moves it once you say where it belongs. Folding a
530
+ free-text label onto an existing project (`log-report` → `Downloads-log-report`)
531
+ happens only when the input is a trailing segment of exactly one known label and
532
+ that label already holds memories; anything ambiguous is left alone, and every
533
+ fold is reported rather than done silently.
534
+
535
+ ---
536
+
478
537
  ## TOON format — compact, readable, diffable
479
538
 
480
539
  Standard markdown memory file:
@@ -441,6 +441,65 @@ memgit thread list / switch / create
441
441
 
442
442
  ---
443
443
 
444
+ ## What memgit reads from your environment
445
+
446
+ memgit runs inside your agent, so the honest answer to "what does this process
447
+ read from my environment?" should be checkable rather than a promise. The table
448
+ below is generated from [`memgit/env.py`](memgit/env.py), and
449
+ `tests/test_env_inventory.py` walks the package on every test run and fails if
450
+ the code reads a name this list does not carry — or carries a name nothing
451
+ reads. The list cannot drift away from the code.
452
+
453
+ memgit reads these and nothing else. It writes none of them. None of them turn
454
+ network access on: the cloud endpoints are overrides for a sync you have already
455
+ opted into with `memgit cloud login`, and with no licence and no cloud login,
456
+ memgit talks to nothing.
457
+
458
+ | | Variable | What memgit does with it |
459
+ |---|---|---|
460
+ | Store and state | `MEMGIT_STORE` | Absolute path to the memory store. When set it is the ONLY candidate — an explicit store never silently falls back to another one. |
461
+ | | `MEMGIT_HOME` | Base directory for memgit's per-person state, currently the licence file (default ~/.memgit, mode 0600). |
462
+ | | `MEMGIT_PROJECT` | Forces the project label instead of detecting it from the working directory. |
463
+ | Attribution | `MEMGIT_AUTHOR` | Explicit author stamped on checkpoints, for multi-agent jobs where several writers share one machine account. Wins over MEMGIT_CLIENT. |
464
+ | | `MEMGIT_CLIENT` | The host that launched this process (claude-code, cursor, ...), stamped into checkpoints so a memory says where it came from. |
465
+ | | `USER` | Fallback author name when neither MEMGIT_AUTHOR nor MEMGIT_CLIENT is set. |
466
+ | | `USERNAME` | Windows fallback for USER. |
467
+ | Licensing (memgit Pro) | `MEMGIT_LICENSE_KEY` | memgit Pro licence key. Never printed in full — `memgit pro status` shows the last four characters. |
468
+ | | `MEMGIT_POLAR_API` | Overrides the licence-validation API base URL (testing). |
469
+ | | `MEMGIT_POLAR_ORG_ID` | Overrides the Polar organisation the licence is checked against. |
470
+ | Cloud sync | `MEMGIT_CLOUD_API` | Overrides the memgit cloud API base URL. |
471
+ | | `MEMGIT_CLOUD_APP` | Overrides the memgit cloud web app base URL used in printed links. |
472
+ | | `MEMGIT_CLOUD_NO_CACHE` | Set to 1/true/yes to keep decrypted keys out of credentials.json; the passphrase is then re-prompted per command. |
473
+ | Tuning | `MEMGIT_LOCK_TIMEOUT` | Seconds a writer waits for the store lock before giving up (default 10). |
474
+ | | `MEMGIT_IDLE_EVICT_SECONDS` | Idle seconds before the MCP server drops its in-memory caches while staying connected (default 900). |
475
+ | | `MEMGIT_HOUSEKEEPING_INTERVAL` | Seconds between the MCP server's housekeeping passes. |
476
+ | Set by other software | `CLAUDE_PROJECT_DIR` | Set by Claude Code for hook processes; used as one input to project detection when the working directory is not the project root. |
477
+ | | `PYTEST_CURRENT_TEST` | Set by pytest. Its presence suppresses automatic backups, so a test run never writes into a real backup location. |
478
+
479
+ ### How the project label is decided
480
+
481
+ Memories are project-scoped, so a wrong label files a memory where you will not
482
+ find it again. The label comes from the first of these that yields one, and
483
+ there is exactly one detection path shared by the MCP server, the CLI and the
484
+ hooks — so a label derived at save time and one derived at recall time cannot
485
+ disagree:
486
+
487
+ 1. `MEMGIT_PROJECT` — a forced label, taken verbatim
488
+ 2. the `cwd` a host reports in its hook payload (the real workspace, even when
489
+ the process working directory is elsewhere)
490
+ 3. `CLAUDE_PROJECT_DIR`
491
+ 4. the process working directory
492
+
493
+ When none of them yields a label — running from `$HOME`, for instance — a write
494
+ is **quarantined** under `_unknown` rather than filed globally, and
495
+ `memgit doctor --relabel` moves it once you say where it belongs. Folding a
496
+ free-text label onto an existing project (`log-report` → `Downloads-log-report`)
497
+ happens only when the input is a trailing segment of exactly one known label and
498
+ that label already holds memories; anything ambiguous is left alone, and every
499
+ fold is reported rather than done silently.
500
+
501
+ ---
502
+
444
503
  ## TOON format — compact, readable, diffable
445
504
 
446
505
  Standard markdown memory file:
@@ -1,3 +1,3 @@
1
1
  """memgit — git for AI memory."""
2
2
 
3
- __version__ = "0.10.0"
3
+ __version__ = "0.11.0"
@@ -118,11 +118,18 @@ def init(directory):
118
118
 
119
119
  # Step-by-step flow: find existing memories automatically and offer the
120
120
  # import, instead of making the user discover the right path themselves.
121
+ # Narrow on purpose: an unreadable ~/.claude tree (OSError) or a memory file
122
+ # that will not parse (ValueError, which UnicodeDecodeError subclasses) is a
123
+ # reason to skip the offer, not to abort `init`. Anything else is a bug in
124
+ # the importer and must reach the user rather than being swallowed here.
121
125
  try:
122
126
  from .importer import from_claude_code
123
127
  found = from_claude_code()
124
- except Exception:
128
+ except (OSError, ValueError) as exc:
125
129
  found = []
130
+ console.print(f'[dim]Could not scan ~/.claude/projects for existing '
131
+ f'memories ({exc.__class__.__name__}: {exc}) — skipping '
132
+ f'the import offer.[/dim]')
126
133
  if found:
127
134
  projects = {m.project for m in found if m.project}
128
135
  console.print(f'\nFound [bold]{len(found)}[/bold] existing Claude Code memories '
@@ -2686,6 +2693,13 @@ def git_init(remote):
2686
2693
 
2687
2694
  Then teammates run `memgit git pull` to get your memories.
2688
2695
  """
2696
+ if remote:
2697
+ from .repo import validate_git_remote
2698
+ bad = validate_git_remote(remote)
2699
+ if bad:
2700
+ err.print(f'[red]{bad}[/red]')
2701
+ raise SystemExit(1)
2702
+
2689
2703
  repo = _require_repo()
2690
2704
  ok = repo.git_init()
2691
2705
  if not ok:
@@ -136,7 +136,12 @@ def cloud():
136
136
  @cloud.command()
137
137
  @click.option('--email', prompt=True)
138
138
  def signup(email):
139
- """Create a memgit cloud account (30-day free trial)."""
139
+ """Create a memgit cloud account (free trial; the server sets the length).
140
+
141
+ The trial length is stamped by the server at signup and printed below, so this
142
+ help text does not hardcode it. It said "30-day" while the deployed server granted
143
+ 14, which is a promise broken on day 15 for every reader of --help.
144
+ """
140
145
  _require_extra()
141
146
  from . import crypto
142
147
  from nacl import utils
@@ -345,13 +350,41 @@ def _report(res, direction):
345
350
  console.print(f'[green]{icons.get(res.action, "·")}[/green] {direction}: {res.action}{detail}{head}')
346
351
 
347
352
 
353
+ def _guard_uncommitted(repo, thread: str, allow: bool) -> None:
354
+ """Refuse to transmit while staged memories are not in a checkpoint.
355
+
356
+ Sync ships CHECKPOINTS. `memgit add` stages a memory but does not create
357
+ one, so pushing straight after `add` uploads nothing of the user's — and
358
+ said "created (2 objects up)" while doing it, with the receiving machine
359
+ reporting a clean, empty, fsck-OK store (measured 2026-09-06). Nothing in
360
+ that sequence errored, which is why it reads as "sync is broken" rather
361
+ than "you have not committed". An error naming the fix is the whole point;
362
+ `--allow-uncommitted` is there for anyone who means it.
363
+ """
364
+ if allow:
365
+ return
366
+ pending = repo.uncommitted_slugs(thread)
367
+ if not pending:
368
+ return
369
+ shown = ', '.join(pending[:5]) + (f' and {len(pending) - 5} more' if len(pending) > 5 else '')
370
+ console.print(
371
+ f'[red]✗[/red] {len(pending)} memor{"y is" if len(pending) == 1 else "ies are"} '
372
+ f'not committed, and sync only ships committed memory: [bold]{shown}[/bold]')
373
+ console.print(' [dim]commit them first: memgit commit -m "what changed"[/dim]')
374
+ console.print(' [dim]or push the committed state anyway: --allow-uncommitted[/dim]')
375
+ sys.exit(1)
376
+
377
+
348
378
  @cloud.command()
349
379
  @click.option('--thread', default=None, help='thread to push (default: current)')
350
- def push(thread):
380
+ @click.option('--allow-uncommitted', is_flag=True,
381
+ help='Push committed state even though staged memories would not be sent')
382
+ def push(thread, allow_uncommitted):
351
383
  """Encrypt and upload local checkpoints, then advance the remote ref."""
352
384
  repo, cstate, api = _ctx()
353
385
  engine = _linked(repo, cstate, api)
354
386
  th = thread or repo.current_thread()
387
+ _guard_uncommitted(repo, th, allow_uncommitted)
355
388
  res = _run(lambda: engine.push(th), 'push')
356
389
  _report(res, f'push {th}')
357
390
 
@@ -369,11 +402,17 @@ def pull(thread):
369
402
 
370
403
  @cloud.command('sync')
371
404
  @click.option('--thread', default=None, help='thread to sync (default: current)')
372
- def cloud_sync(thread):
405
+ @click.option('--allow-uncommitted', is_flag=True,
406
+ help='Sync committed state even though staged memories would not be sent')
407
+ def cloud_sync(thread, allow_uncommitted):
373
408
  """Pull then push — the everyday command."""
374
409
  repo, cstate, api = _ctx()
375
410
  engine = _linked(repo, cstate, api)
376
411
  th = thread or repo.current_thread()
412
+ # Guarded before the pull as well as the push: sync is the command users
413
+ # reach for, and finding out after a merge that half of it was a no-op is
414
+ # worse than being told up front which memories are not committed yet.
415
+ _guard_uncommitted(repo, th, allow_uncommitted)
377
416
  pulled, pushed = _run(lambda: engine.sync(th), 'sync')
378
417
  _report(pulled, f'pull {th}')
379
418
  _report(pushed, f'push {th}')
@@ -0,0 +1,88 @@
1
+ """Every environment variable memgit reads, in one place.
2
+
3
+ A memory tool runs inside someone else's agent, so "what does this process read
4
+ from my environment?" deserves an answer that is checkable rather than a claim
5
+ in a README. This module is that answer, and `tests/test_env_inventory.py`
6
+ walks the package's AST on every test run and fails if the code reads a name
7
+ that is not listed here, or if a name listed here is read nowhere — so the
8
+ inventory cannot drift away from the code the way documentation does.
9
+
10
+ memgit reads these and nothing else. It writes none of them, and it never reads
11
+ an environment variable to decide whether to send data anywhere: the cloud
12
+ endpoints below are overrides for a sync the user has already opted into by
13
+ running `memgit cloud login`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ #: name -> what memgit does with it. Keep the description to one line.
19
+ KNOWN_ENV_VARS: dict[str, str] = {
20
+ # ── where the store and memgit's own state live ──────────────────────────
21
+ 'MEMGIT_STORE': (
22
+ 'Absolute path to the memory store. When set it is the ONLY candidate '
23
+ '— an explicit store never silently falls back to another one.'
24
+ ),
25
+ 'MEMGIT_HOME': (
26
+ "Base directory for memgit's per-person state, currently the licence "
27
+ 'file (default ~/.memgit, mode 0600).'
28
+ ),
29
+ 'MEMGIT_PROJECT': (
30
+ 'Forces the project label instead of detecting it from the working '
31
+ 'directory.'
32
+ ),
33
+
34
+ # ── attribution ──────────────────────────────────────────────────────────
35
+ 'MEMGIT_AUTHOR': (
36
+ 'Explicit author stamped on checkpoints, for multi-agent jobs where '
37
+ 'several writers share one machine account. Wins over MEMGIT_CLIENT.'
38
+ ),
39
+ 'MEMGIT_CLIENT': (
40
+ 'The host that launched this process (claude-code, cursor, ...), '
41
+ 'stamped into checkpoints so a memory says where it came from.'
42
+ ),
43
+ 'USER': 'Fallback author name when neither MEMGIT_AUTHOR nor MEMGIT_CLIENT is set.',
44
+ 'USERNAME': 'Windows fallback for USER.',
45
+
46
+ # ── licensing (memgit Pro) ───────────────────────────────────────────────
47
+ 'MEMGIT_LICENSE_KEY': (
48
+ 'memgit Pro licence key. Never printed in full — `memgit pro status` '
49
+ 'shows the last four characters.'
50
+ ),
51
+ 'MEMGIT_POLAR_API': 'Overrides the licence-validation API base URL (testing).',
52
+ 'MEMGIT_POLAR_ORG_ID': 'Overrides the Polar organisation the licence is checked against.',
53
+
54
+ # ── cloud sync (only used after `memgit cloud login`) ────────────────────
55
+ 'MEMGIT_CLOUD_API': 'Overrides the memgit cloud API base URL.',
56
+ 'MEMGIT_CLOUD_APP': 'Overrides the memgit cloud web app base URL used in printed links.',
57
+ 'MEMGIT_CLOUD_NO_CACHE': (
58
+ 'Set to 1/true/yes to keep decrypted keys out of credentials.json; the '
59
+ 'passphrase is then re-prompted per command.'
60
+ ),
61
+
62
+ # ── tuning ───────────────────────────────────────────────────────────────
63
+ 'MEMGIT_LOCK_TIMEOUT': 'Seconds a writer waits for the store lock before giving up (default 10).',
64
+ 'MEMGIT_IDLE_EVICT_SECONDS': (
65
+ 'Idle seconds before the MCP server drops its in-memory caches while '
66
+ 'staying connected (default 900).'
67
+ ),
68
+ 'MEMGIT_HOUSEKEEPING_INTERVAL': 'Seconds between the MCP server\'s housekeeping passes.',
69
+
70
+ # ── set by other software, read but never written by memgit ─────────────
71
+ 'CLAUDE_PROJECT_DIR': (
72
+ 'Set by Claude Code for hook processes; used as one input to project '
73
+ 'detection when the working directory is not the project root.'
74
+ ),
75
+ 'PYTEST_CURRENT_TEST': (
76
+ 'Set by pytest. Its presence suppresses automatic backups, so a test '
77
+ 'run never writes into a real backup location.'
78
+ ),
79
+ }
80
+
81
+
82
+ def render_table() -> str:
83
+ """The inventory as plain aligned text, for `--help` output or a README."""
84
+ width = max(len(name) for name in KNOWN_ENV_VARS)
85
+ return '\n'.join(
86
+ f'{name.ljust(width)} {purpose}'
87
+ for name, purpose in sorted(KNOWN_ENV_VARS.items())
88
+ )
@@ -230,7 +230,7 @@ def _require_compatible_sdk() -> None:
230
230
  sys.stderr.write(
231
231
  f"memgit: the installed MCP SDK (mcp {found}) is not compatible with "
232
232
  f"this version of memgit — it no longer provides Server.list_tools.\n"
233
- f"memgit needs mcp 1.x. Fix with: pip install 'mcp>=1.0.0,<2'\n"
233
+ f"memgit needs mcp 1.x. Fix with: pip install 'mcp>=1.28.1,<2'\n"
234
234
  f"(If you installed through npx memgit-mcp, delete "
235
235
  f"~/.memgit-npm-venv and run it again.)\n")
236
236
  raise SystemExit(1)
@@ -18,6 +18,77 @@ from .models import (
18
18
  from .store import ObjectStore, object_cache as _object_cache
19
19
 
20
20
 
21
+ # ── git argument safety ───────────────────────────────────────────────────────
22
+ #
23
+ # `git_push` / `git_pull` / `memgit git init --remote` hand their `remote` and
24
+ # `branch` straight to `subprocess.run(['git', ...])`. No shell is involved, so
25
+ # there is nothing to quote — but argv is not inert either:
26
+ #
27
+ # * git reads any argument starting with '-' as an OPTION, not as a
28
+ # positional. That is how a remote name becomes `--upload-pack=<cmd>` or
29
+ # `--receive-pack=<cmd>`, both of which are commands git then runs.
30
+ # * a remote URL using the `ext::` transport IS a command line by definition —
31
+ # git-remote-ext executes it. `fd::` is the same class.
32
+ # * a newline in a branch name splits config and refspec parsing downstream.
33
+ #
34
+ # The values arrive from a CLI argument, a teammate's checked-in config or an
35
+ # MCP caller, so they are checked before they become argv. These return an error
36
+ # STRING rather than raising: every caller here already reports (ok, message).
37
+
38
+ _GIT_REMOTE_DENY_SCHEMES = ('ext::', 'fd::')
39
+
40
+ #: git check-ref-format, restricted to what a branch name may never contain.
41
+ _BRANCH_FORBIDDEN_CHARS = set(' \t~^:?*[\\')
42
+
43
+
44
+ def validate_git_remote(remote: str) -> Optional[str]:
45
+ """Error message if `remote` is unsafe to pass to git as a remote, else None.
46
+
47
+ Accepts both a remote NAME (`origin`) and a URL, because git's positional
48
+ accepts either. Rejects option-shaped values and the transports whose URL is
49
+ an executable command.
50
+ """
51
+ if not isinstance(remote, str) or not remote.strip():
52
+ return 'Refusing git remote: empty'
53
+ if remote.startswith('-'):
54
+ return (f'Refusing git remote {remote!r}: a leading "-" makes git read '
55
+ f'it as an option, not a remote')
56
+ if any(c in remote for c in ('\n', '\r', '\0')):
57
+ return f'Refusing git remote {remote!r}: contains a control character'
58
+ lowered = remote.lower()
59
+ for scheme in _GIT_REMOTE_DENY_SCHEMES:
60
+ if lowered.startswith(scheme):
61
+ return (f'Refusing git remote {remote!r}: the "{scheme}" transport '
62
+ f'runs its URL as a command')
63
+ return None
64
+
65
+
66
+ def validate_git_branch(branch: str) -> Optional[str]:
67
+ """Error message if `branch` is not a valid, safe branch name, else None.
68
+
69
+ A subset of `git check-ref-format --branch`, applied without shelling out to
70
+ git so the refusal happens before any process is spawned.
71
+ """
72
+ if not isinstance(branch, str) or not branch.strip():
73
+ return 'Refusing git branch: empty'
74
+ if branch.startswith('-'):
75
+ return (f'Refusing git branch {branch!r}: a leading "-" makes git read '
76
+ f'it as an option, not a branch')
77
+ if any(c in branch for c in _BRANCH_FORBIDDEN_CHARS):
78
+ return f'Refusing git branch {branch!r}: contains a character git forbids'
79
+ if any(ord(c) < 0x20 or ord(c) == 0x7F for c in branch):
80
+ return f'Refusing git branch {branch!r}: contains a control character'
81
+ if '..' in branch or '@{' in branch:
82
+ return f'Refusing git branch {branch!r}: contains ".." or "@{{"'
83
+ if branch == '@' or branch.endswith('.lock') or branch.endswith('/'):
84
+ return f'Refusing git branch {branch!r}: not a valid ref name'
85
+ if branch.startswith('/') or branch.startswith('.') or branch.endswith('.'):
86
+ return f'Refusing git branch {branch!r}: not a valid ref name'
87
+ if '//' in branch:
88
+ return f'Refusing git branch {branch!r}: not a valid ref name'
89
+ return None
90
+
91
+
21
92
  def default_store_candidates() -> list[Path]:
22
93
  """Well-known store locations, in the same order `memgit init` auto-detects.
23
94
 
@@ -67,26 +138,82 @@ class Repository:
67
138
  return self.path / 'memgit.lock'
68
139
 
69
140
  def _try_break_stale_lock(self):
70
- """Remove the lockfile if its owner is gone or it has aged out."""
141
+ """Remove the lockfile only when its owner is provably gone, or it aged out.
142
+
143
+ 🔴 An EMPTY lockfile is a lock being BORN, never an abandoned one.
144
+ Acquisition is `os.open(O_CREAT|O_EXCL)` followed by `os.write` of the
145
+ owner token, and between those two calls the file exists with zero
146
+ bytes. The previous version read that empty file, computed `pid = 0`,
147
+ skipped the liveness check because `pid > 0` was false, and fell
148
+ through to `not pid_alive` — deleting a lock that was alive and about
149
+ to be used. The thread then acquired the same lock, and two writers ran
150
+ the read-modify-write of TOON_INDEX at once, so one memory was dropped
151
+ from both the index and HEAD's MindState with no error raised and a
152
+ clean fsck. Measured 2026-09-06: 3/120 runs lost a memory, and a
153
+ holder-counter saw max 3 concurrent holders with 5 exclusion
154
+ violations. Treating an unstamped lock as dead is the whole bug.
155
+ """
71
156
  lp = self._lock_path
72
157
  try:
73
158
  raw = lp.read_text().split()
74
- pid = int(raw[0]) if raw else 0
75
159
  age = time.time() - lp.stat().st_mtime
76
160
  except (OSError, ValueError):
161
+ return # vanished under us, or unreadable — not ours to break
162
+
163
+ aged_out = age > LOCK_STALE_SECONDS
164
+ if not raw:
165
+ # Unstamped: someone is between O_EXCL and the write. Only an
166
+ # aged-out lock this shape is genuinely abandoned (a crash in the
167
+ # window), and LOCK_STALE_SECONDS is long enough to be safe.
168
+ if aged_out:
169
+ self._unlink_lock_if(lp, expect=None)
77
170
  return
78
- pid_alive = False
171
+
172
+ try:
173
+ pid = int(raw[0])
174
+ except ValueError:
175
+ if aged_out:
176
+ self._unlink_lock_if(lp, expect=None)
177
+ return
178
+
179
+ owner_dead = False
79
180
  if pid > 0:
80
181
  try:
81
182
  os.kill(pid, 0)
82
- pid_alive = True
83
- except OSError:
84
- pid_alive = False
85
- if not pid_alive or age > LOCK_STALE_SECONDS:
86
- try:
87
- lp.unlink()
183
+ except ProcessLookupError:
184
+ owner_dead = True
88
185
  except OSError:
89
- pass
186
+ owner_dead = False # e.g. EPERM: alive, just not ours to signal
187
+ if owner_dead or aged_out:
188
+ self._unlink_lock_if(lp, expect=self._lock_token(raw))
189
+
190
+ @staticmethod
191
+ def _lock_token(raw: list[str]) -> str:
192
+ """The owner identity inside a lockfile: `pid uuid`, ignoring the timestamp.
193
+
194
+ The file is written as `pid uuid unixtime`. Comparing the WHOLE line
195
+ against a token is a bug that costs the lock its release: the token has
196
+ two fields, the file has three, so equality never held and every
197
+ acquisition leaked until it aged out.
198
+ """
199
+ return ' '.join(raw[:2])
200
+
201
+ @staticmethod
202
+ def _unlink_lock_if(lp: Path, expect: Optional[str]) -> None:
203
+ """Delete the lockfile only if it is still the one we judged.
204
+
205
+ Without this the breaker can delete a lock created by someone else in
206
+ the microseconds since it decided to break, and a releasing holder can
207
+ drop a lock that was taken over — either way turning one stale lock
208
+ into a live race.
209
+ """
210
+ try:
211
+ if expect is not None:
212
+ if Repository._lock_token(lp.read_text().split()) != expect:
213
+ return
214
+ lp.unlink()
215
+ except OSError:
216
+ pass
90
217
 
91
218
  @contextmanager
92
219
  def _lock(self, timeout: float = None):
@@ -106,11 +233,17 @@ class Repository:
106
233
 
107
234
  timeout = LOCK_TIMEOUT_SECONDS if timeout is None else timeout
108
235
  deadline = time.monotonic() + timeout
236
+ # Token identifies THIS acquisition, not just this process: threads in
237
+ # one process share a pid, and release must never delete a lock that
238
+ # someone else has since taken.
239
+ token = f'{os.getpid()} {uuid.uuid4().hex}'
109
240
  while True:
110
241
  try:
111
242
  fd = os.open(self._lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
112
- os.write(fd, f'{os.getpid()} {time.time():.0f}\n'.encode())
113
- os.close(fd)
243
+ try:
244
+ os.write(fd, f'{token} {time.time():.0f}\n'.encode())
245
+ finally:
246
+ os.close(fd)
114
247
  break
115
248
  except FileExistsError:
116
249
  self._try_break_stale_lock()
@@ -126,10 +259,10 @@ class Repository:
126
259
  yield
127
260
  finally:
128
261
  self._lock_depth = 0
129
- try:
130
- self._lock_path.unlink()
131
- except OSError:
132
- pass
262
+ # Only drop the lock if it is still OURS. If a breaker removed it
263
+ # and another writer acquired, unlinking here would release THEIR
264
+ # critical section and cascade the very race this lock prevents.
265
+ self._unlink_lock_if(self._lock_path, expect=token)
133
266
 
134
267
  # ── Discovery ─────────────────────────────────────────────────────────────
135
268
 
@@ -406,6 +539,22 @@ class Repository:
406
539
 
407
540
  # ── Commit ────────────────────────────────────────────────────────────────
408
541
 
542
+ def uncommitted_slugs(self, thread: Optional[str] = None) -> list[str]:
543
+ """Slugs staged in the index but not in the thread HEAD's MindState.
544
+
545
+ `add` writes a mnemonic and stages it; only `commit` puts it in a
546
+ checkpoint. Everything that leaves this machine — cloud push, git sync,
547
+ backup — ships CHECKPOINTS, so a staged-but-uncommitted memory is
548
+ invisible to all of them. Callers that are about to transmit state
549
+ should say so rather than reporting a successful no-op: measured
550
+ 2026-09-06, `cloud push` answered "created (2 objects up)" while
551
+ shipping none of the user's memory, and the receiving machine then
552
+ reported a clean, empty, fsck-OK store.
553
+ """
554
+ index = self.get_index()
555
+ committed = self._mindstate_map(self.head_sha(thread))
556
+ return sorted(s for s, sha in index.items() if committed.get(s) != sha)
557
+
409
558
  def _mindstate_map(self, ck_sha: Optional[str]) -> dict[str, str]:
410
559
  """slug → mnem_sha map for a checkpoint's MindState ({} for None)."""
411
560
  if not ck_sha:
@@ -1385,6 +1534,10 @@ class Repository:
1385
1534
  except Exception:
1386
1535
  return None
1387
1536
 
1537
+ def _check_git_args(self, remote: str, branch: str) -> Optional[str]:
1538
+ """Error message if these git arguments are unsafe, else None."""
1539
+ return validate_git_remote(remote) or validate_git_branch(branch)
1540
+
1388
1541
  def git_push(self, remote: str = 'origin', branch: str = 'main',
1389
1542
  message: str = None) -> tuple[bool, str]:
1390
1543
  """Write flat files then `git add + commit + push`.
@@ -1394,6 +1547,9 @@ class Repository:
1394
1547
  store_root = self.path.parent
1395
1548
  if not (store_root / '.git').exists():
1396
1549
  return False, 'Not a git repo — run `memgit git init` first'
1550
+ bad = self._check_git_args(remote, branch)
1551
+ if bad:
1552
+ return False, bad
1397
1553
  self.write_flat()
1398
1554
  head_sha = self.head_sha() or 'none'
1399
1555
  commit_msg = message or f'memgit: checkpoint {head_sha[:8]}'
@@ -1422,6 +1578,9 @@ class Repository:
1422
1578
  store_root = self.path.parent
1423
1579
  if not (store_root / '.git').exists():
1424
1580
  return False, 'Not a git repo', 0
1581
+ bad = self._check_git_args(remote, branch)
1582
+ if bad:
1583
+ return False, bad, 0
1425
1584
  try:
1426
1585
  subprocess.run(['git', 'pull', remote, branch], cwd=store_root,
1427
1586
  check=True, capture_output=True)
@@ -121,8 +121,27 @@ def _tracked_files(root: Path) -> list[Path]:
121
121
  instead, which is what .gitignore was doing for us.
122
122
  """
123
123
  out = []
124
- for path in root.rglob("*"):
125
- if not path.is_file():
124
+ # `is_file()` stats the path, and a stat can fail for reasons that have
125
+ # nothing to do with us: /proc entries, dead symlinks, directories the user
126
+ # cannot traverse. An unreadable file is one fewer place a fact could have
127
+ # been found, never a reason to abort the whole report — `memgit savings`
128
+ # crashed with an unhandled PermissionError on /proc/1/map_files when run
129
+ # from / in a container (measured 2026-09-06).
130
+ try:
131
+ walk = root.rglob("*")
132
+ except OSError:
133
+ return out
134
+ while True:
135
+ try:
136
+ path = next(walk)
137
+ except StopIteration:
138
+ break
139
+ except OSError:
140
+ continue
141
+ try:
142
+ if not path.is_file():
143
+ continue
144
+ except OSError:
126
145
  continue
127
146
  if any(part in _SKIP_DIRS for part in path.parts):
128
147
  continue