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.
- {memgit-0.10.0 → memgit-0.11.0}/PKG-INFO +61 -2
- {memgit-0.10.0 → memgit-0.11.0}/README.md +59 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/__init__.py +1 -1
- {memgit-0.10.0 → memgit-0.11.0}/memgit/cli.py +15 -1
- {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/commands.py +42 -3
- memgit-0.11.0/memgit/env.py +88 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/mcp_server.py +1 -1
- {memgit-0.10.0 → memgit-0.11.0}/memgit/repo.py +175 -16
- {memgit-0.10.0 → memgit-0.11.0}/memgit/savings.py +21 -2
- {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/PKG-INFO +61 -2
- {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/SOURCES.txt +8 -1
- {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/requires.txt +1 -1
- {memgit-0.10.0 → memgit-0.11.0}/pyproject.toml +23 -9
- memgit-0.11.0/tests/test_cloud_guard.py +66 -0
- memgit-0.11.0/tests/test_dependency_floor.py +78 -0
- memgit-0.11.0/tests/test_env_inventory.py +86 -0
- memgit-0.11.0/tests/test_git_args.py +71 -0
- memgit-0.11.0/tests/test_lock_exclusion.py +87 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_savings.py +31 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_server_lifecycle.py +11 -1
- memgit-0.11.0/tests/test_version_stamps.py +83 -0
- {memgit-0.10.0 → memgit-0.11.0}/LICENSE +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/backup.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/__init__.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/client.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/crypto.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/state.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/cloud/sync.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/delivery.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/evaluate.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/gitdigest.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/graph.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/hooks.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/http_server.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/importer.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/license.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/links.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/metrics.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/models.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/project.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/sanitize.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/scorer.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/store.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/tokens.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/toon.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit/usage.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/dependency_links.txt +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/entry_points.txt +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/memgit.egg-info/top_level.txt +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/setup.cfg +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_accrue.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_advanced.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_aliases.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_backup.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_core.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_delivery.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_license.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_setup.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_store_repo.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_toon.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_v020.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_v030.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_v040.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_v060.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_v070.py +0 -0
- {memgit-0.10.0 → memgit-0.11.0}/tests/test_v080.py +0 -0
- {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.
|
|
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.
|
|
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:
|
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|