protonfs 0.1.dev43__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.
@@ -0,0 +1,179 @@
1
+ # src/protonfs/commands/setup.py
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from protonfs.commands.push import push as push_files
10
+ from protonfs.config import Config, init_config, load_config
11
+ from protonfs.context import RepoContext
12
+ from protonfs.drive import DriveClient
13
+ from protonfs.ignore import init_ignore
14
+ from protonfs.index import IndexStore
15
+ from protonfs.lfs import find_pointer_stubs, is_lfs_tracked
16
+
17
+
18
+ def ensure_cli_present(drive: DriveClient) -> str:
19
+ version = drive.version()
20
+ if version is None:
21
+ raise click.ClickException(
22
+ "proton-drive CLI not found. Install it from "
23
+ "https://proton.me/download/drive/cli/index.html and ensure it's on PATH, "
24
+ "then re-run `protonfs setup`."
25
+ )
26
+ return version
27
+
28
+
29
+ def ensure_authenticated(drive: DriveClient) -> None:
30
+ if not drive.is_authenticated():
31
+ raise click.ClickException(
32
+ "Not authenticated with Proton Drive. Run `proton-drive auth login`, "
33
+ "then re-run `protonfs setup`."
34
+ )
35
+
36
+
37
+ def ensure_config(root: Path) -> Config:
38
+ existing = load_config(root)
39
+ if existing is not None:
40
+ return existing
41
+ remote_root = click.prompt("Remote Drive root path for this repo (e.g. /my-files/myproject)")
42
+ return init_config(root, remote_root)
43
+
44
+
45
+ def _untrack_lfs_patterns(root: Path) -> list[str]:
46
+ gitattributes = root / ".gitattributes"
47
+ if not gitattributes.exists():
48
+ return []
49
+ removed_patterns: list[str] = []
50
+ kept_lines: list[str] = []
51
+ for line in gitattributes.read_text().splitlines():
52
+ if "filter=lfs" in line:
53
+ removed_patterns.append(line.split()[0])
54
+ else:
55
+ kept_lines.append(line)
56
+ gitattributes.write_text("\n".join(kept_lines) + ("\n" if kept_lines else ""))
57
+ return removed_patterns
58
+
59
+
60
+ def _append_gitignore(root: Path, patterns: list[str]) -> None:
61
+ gitignore = root / ".gitignore"
62
+ existing = gitignore.read_text() if gitignore.exists() else ""
63
+ existing_lines = {line.strip() for line in existing.splitlines()}
64
+ lines_to_add = [p for p in patterns if p.strip() not in existing_lines]
65
+ if not lines_to_add:
66
+ return
67
+ with gitignore.open("a") as fh:
68
+ if existing and not existing.endswith("\n"):
69
+ fh.write("\n")
70
+ fh.write("\n".join(lines_to_add) + "\n")
71
+
72
+
73
+ def migrate_lfs(ctx: RepoContext, dry_run: bool = False) -> bool:
74
+ root = ctx.root
75
+ if not is_lfs_tracked(root):
76
+ click.echo("No git-lfs tracking found -- skipping migration.")
77
+ return False
78
+
79
+ click.echo("git-lfs tracking detected.")
80
+ if dry_run:
81
+ click.echo("[dry-run] Would run: git lfs pull")
82
+ click.echo("[dry-run] Would upload LFS-tracked files to Drive via `protonfs push`.")
83
+ click.echo(
84
+ "[dry-run] Would remove LFS filter rules from .gitattributes, add to .gitignore,"
85
+ )
86
+ click.echo("[dry-run] git rm --cached the affected paths, and commit.")
87
+ return True
88
+
89
+ click.echo("Materializing real file content (git lfs pull)...")
90
+ subprocess.run(["git", "-C", str(root), "lfs", "pull"], check=True)
91
+
92
+ click.echo("Uploading LFS-tracked files to Drive before touching git tracking...")
93
+ result = push_files(ctx, subpath=None, resolve=None, dry_run=False)
94
+ if result.failed_items:
95
+ raise click.ClickException(
96
+ f"{result.failed_items} file(s) failed to upload -- aborting migration before "
97
+ "touching git tracking. Re-run `protonfs setup` once uploads succeed."
98
+ )
99
+
100
+ click.confirm(
101
+ f"Uploaded {result.transferred_items} file(s) to Drive. This will now remove LFS "
102
+ "filter rules from .gitattributes, gitignore the same paths, `git rm --cached` them, "
103
+ "and create one commit. Continue?",
104
+ abort=True,
105
+ )
106
+ patterns = _untrack_lfs_patterns(root)
107
+ _append_gitignore(root, patterns)
108
+ try:
109
+ subprocess.run(
110
+ ["git", "-C", str(root), "add", ".gitattributes", ".gitignore"], check=True
111
+ )
112
+ for pattern in patterns:
113
+ subprocess.run(
114
+ ["git", "-C", str(root), "rm", "-r", "--cached", "--ignore-unmatch", pattern],
115
+ check=True,
116
+ )
117
+ subprocess.run(
118
+ [
119
+ "git", "-C", str(root), "commit", "-m",
120
+ "chore: migrate dump storage from git-lfs to protonfs/Proton Drive",
121
+ ],
122
+ check=True,
123
+ )
124
+ except subprocess.CalledProcessError as exc:
125
+ raise click.ClickException(
126
+ f"git step of the LFS migration failed ({exc}). The files were already "
127
+ "uploaded to Drive successfully, but your working tree may now be "
128
+ "mid-migration -- inspect `git status` and revert/complete the git state "
129
+ "manually before re-running `protonfs setup`."
130
+ ) from exc
131
+ click.echo("Migration commit created. Review it before pushing.")
132
+ return True
133
+
134
+
135
+ def clean_pointer_stubs(root: Path) -> int:
136
+ stubs = find_pointer_stubs(root, Path("."))
137
+ for stub in stubs:
138
+ stub.unlink()
139
+ return len(stubs)
140
+
141
+
142
+ def maybe_uninstall_lfs_filters(root: Path) -> None:
143
+ gitattributes = root / ".gitattributes"
144
+ still_has_lfs = gitattributes.exists() and "filter=lfs" in gitattributes.read_text()
145
+ if still_has_lfs:
146
+ return
147
+ result = subprocess.run(["git", "-C", str(root), "lfs", "env"], capture_output=True, text=True)
148
+ if result.returncode != 0:
149
+ return # git-lfs not installed at all -- nothing to uninstall
150
+ click.echo("No LFS patterns remain in .gitattributes; running `git lfs uninstall`.")
151
+ subprocess.run(["git", "-C", str(root), "lfs", "uninstall"], check=True)
152
+
153
+
154
+ def run_setup(root: Path, dry_run: bool = False) -> None:
155
+ drive = DriveClient()
156
+ version = ensure_cli_present(drive)
157
+ click.echo(f"proton-drive CLI found: {version}")
158
+
159
+ ensure_authenticated(drive)
160
+ click.echo("Authenticated with Proton Drive.")
161
+
162
+ config = ensure_config(root)
163
+ init_ignore(root)
164
+ config_file = root / ".protonfs" / "config.json"
165
+ click.echo(f"Config ready at {config_file} (remote_root={config.remote_root}).")
166
+
167
+ ctx = RepoContext(root=root, config=config, index=IndexStore(root), drive=drive)
168
+ migrated = migrate_lfs(ctx, dry_run=dry_run)
169
+
170
+ removed = clean_pointer_stubs(root)
171
+ if removed:
172
+ click.echo(f"Removed {removed} leftover git-lfs pointer stub file(s).")
173
+ else:
174
+ click.echo("No leftover git-lfs pointer stubs found.")
175
+
176
+ if migrated and not dry_run:
177
+ maybe_uninstall_lfs_filters(root)
178
+
179
+ click.echo("protonfs setup complete.")
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from pathlib import Path
5
+
6
+ from protonfs.context import RepoContext
7
+ from protonfs.diff import classify
8
+ from protonfs.ignore import IgnoreMatcher
9
+ from protonfs.localscan import scan
10
+
11
+
12
+ def compute_status(ctx: RepoContext, subpath: str | None) -> Counter:
13
+ ignore = IgnoreMatcher.from_file(ctx.root)
14
+ scan_root = Path(subpath) if subpath else Path(".")
15
+ local = scan(ctx.root, scan_root, ignore, ctx.index, low_io=ctx.config.defaults.low_io)
16
+ entries = classify(local, ctx.index)
17
+ return Counter(entry.state.value for entry in entries)
protonfs/config.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from dataclasses import asdict, dataclass, field
6
+ from pathlib import Path
7
+
8
+ CONFIG_DIR_NAME = ".protonfs"
9
+ CONFIG_FILE_NAME = "config.json"
10
+
11
+
12
+ @dataclass
13
+ class Defaults:
14
+ on_conflict: str = "skip"
15
+ low_io: bool = False
16
+
17
+
18
+ @dataclass
19
+ class Config:
20
+ remote_root: str
21
+ device_id: str
22
+ defaults: Defaults = field(default_factory=Defaults)
23
+
24
+ def to_dict(self) -> dict:
25
+ return {
26
+ "remote_root": self.remote_root,
27
+ "device_id": self.device_id,
28
+ "defaults": asdict(self.defaults),
29
+ }
30
+
31
+ @classmethod
32
+ def from_dict(cls, data: dict) -> Config:
33
+ defaults_data = data.get("defaults", {})
34
+ return cls(
35
+ remote_root=data["remote_root"],
36
+ device_id=data["device_id"],
37
+ defaults=Defaults(
38
+ on_conflict=defaults_data.get("on_conflict", "skip"),
39
+ low_io=defaults_data.get("low_io", False),
40
+ ),
41
+ )
42
+
43
+
44
+ def config_dir(repo_root: Path) -> Path:
45
+ return repo_root / CONFIG_DIR_NAME
46
+
47
+
48
+ def config_path(repo_root: Path) -> Path:
49
+ return config_dir(repo_root) / CONFIG_FILE_NAME
50
+
51
+
52
+ def load_config(repo_root: Path) -> Config | None:
53
+ path = config_path(repo_root)
54
+ if not path.exists():
55
+ return None
56
+ return Config.from_dict(json.loads(path.read_text()))
57
+
58
+
59
+ def save_config(repo_root: Path, config: Config) -> None:
60
+ config_dir(repo_root).mkdir(parents=True, exist_ok=True)
61
+ config_path(repo_root).write_text(json.dumps(config.to_dict(), indent=2) + "\n")
62
+
63
+
64
+ def init_config(repo_root: Path, remote_root: str) -> Config:
65
+ config = Config(remote_root=remote_root, device_id=str(uuid.uuid4()))
66
+ save_config(repo_root, config)
67
+ return config
protonfs/context.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from protonfs.config import Config, load_config
9
+ from protonfs.drive import DriveClient
10
+ from protonfs.index import IndexStore
11
+
12
+
13
+ @dataclass
14
+ class RepoContext:
15
+ root: Path
16
+ config: Config
17
+ index: IndexStore
18
+ drive: DriveClient
19
+
20
+
21
+ def load_context(start: Path | None = None) -> RepoContext:
22
+ root = (start or Path.cwd()).resolve()
23
+ config = load_config(root)
24
+ if config is None:
25
+ raise click.ClickException(
26
+ "protonfs is not set up in this directory. Run `protonfs setup` first."
27
+ )
28
+ return RepoContext(root=root, config=config, index=IndexStore(root), drive=DriveClient())
protonfs/diff.py ADDED
@@ -0,0 +1,83 @@
1
+ # src/protonfs/diff.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+
7
+ from protonfs.index import IndexStore
8
+ from protonfs.localscan import ScanEntry
9
+
10
+
11
+ class SyncState(str, Enum):
12
+ SYNCED = "synced"
13
+ LOCAL_ONLY = "local-only"
14
+ REMOTE_ONLY = "remote-only"
15
+ METADATA_ONLY = "metadata-only"
16
+ CONFLICT = "conflict"
17
+ REMOTE_CHANGED = "remote-changed"
18
+ REMOTE_DELETED = "remote-deleted"
19
+
20
+
21
+ @dataclass
22
+ class DiffEntry:
23
+ rel_path: str
24
+ state: SyncState
25
+
26
+
27
+ def within_subpath(rel_path: str, subpath: str | None) -> bool:
28
+ """True when rel_path lies inside `subpath` (or when there is no subpath).
29
+
30
+ `classify` reasons over the whole repo-wide index, so a caller that scoped its
31
+ local scan and remote walk to a subpath MUST filter classify's output with this
32
+ before acting on it -- otherwise index entries outside the subpath (never
33
+ scanned, never walked) are misread as remote-deleted / remote-only.
34
+ """
35
+ if not subpath:
36
+ return True
37
+ return rel_path == subpath or rel_path.startswith(f"{subpath}/")
38
+
39
+
40
+ def classify(
41
+ local: dict[str, ScanEntry],
42
+ index: IndexStore,
43
+ remote: dict[str, int] | None = None,
44
+ ) -> list[DiffEntry]:
45
+ known_paths = set(local) | set(index.all())
46
+ if remote is not None:
47
+ known_paths |= set(remote)
48
+
49
+ results: list[DiffEntry] = []
50
+ for rel_path in sorted(known_paths):
51
+ local_entry = local.get(rel_path)
52
+ index_entry = index.get(rel_path)
53
+
54
+ if local_entry is not None and index_entry is None:
55
+ state = SyncState.LOCAL_ONLY
56
+ elif local_entry is not None and index_entry is not None:
57
+ state = (
58
+ SyncState.SYNCED
59
+ if local_entry.sha256 == index_entry.sha256
60
+ else SyncState.CONFLICT
61
+ )
62
+ elif local_entry is None and index_entry is not None:
63
+ if remote is not None:
64
+ if rel_path not in remote:
65
+ state = SyncState.REMOTE_DELETED
66
+ elif remote[rel_path] != index_entry.size:
67
+ state = SyncState.REMOTE_CHANGED
68
+ else:
69
+ state = (
70
+ SyncState.METADATA_ONLY
71
+ if index_entry.local_state == "metadata-only"
72
+ else SyncState.REMOTE_ONLY
73
+ )
74
+ else:
75
+ state = (
76
+ SyncState.METADATA_ONLY
77
+ if index_entry.local_state == "metadata-only"
78
+ else SyncState.REMOTE_ONLY
79
+ )
80
+ else:
81
+ state = SyncState.REMOTE_ONLY
82
+ results.append(DiffEntry(rel_path, state))
83
+ return results
protonfs/drive.py ADDED
@@ -0,0 +1,221 @@
1
+ # src/protonfs/drive.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from collections import deque
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ BINARY_ENV_VAR = "PROTONFS_DRIVE_BIN"
16
+ DEFAULT_BINARY = "proton-drive"
17
+
18
+
19
+ class DriveError(RuntimeError):
20
+ pass
21
+
22
+
23
+ class DriveAuthError(DriveError):
24
+ pass
25
+
26
+
27
+ @dataclass
28
+ class TransferResult:
29
+ transferred_items: int
30
+ skipped_items: int
31
+ failed_items: int
32
+ failures: list[dict]
33
+
34
+ @classmethod
35
+ def from_json(cls, data: dict) -> TransferResult:
36
+ return cls(
37
+ transferred_items=data.get("transferredItems", 0),
38
+ skipped_items=data.get("skippedItems", 0),
39
+ failed_items=data.get("failedItems", 0),
40
+ failures=data.get("failures", []),
41
+ )
42
+
43
+
44
+ @dataclass
45
+ class RemoteEntry:
46
+ rel_path: str
47
+ is_dir: bool
48
+ size: int
49
+
50
+
51
+ def binary_path() -> str:
52
+ return os.environ.get(BINARY_ENV_VAR, DEFAULT_BINARY)
53
+
54
+
55
+ # Specific phrases that signal a genuine auth failure. Tightened from the v0.1 broad
56
+ # `"auth" in message` check, which false-positived on unrelated words ("author",
57
+ # "unauthorized"/permission errors). D5.1: we prefer a false negative (a real auth
58
+ # error surfaced as a generic DriveError, still an error) over a false positive
59
+ # (a non-auth error mislabelled as auth). If proton-drive's exact wording is later
60
+ # captured via a deliberate logout probe, add it here.
61
+ _AUTH_ERROR_SIGNALS = (
62
+ "unauthenticated",
63
+ "not authenticated",
64
+ "not logged in",
65
+ "log in first",
66
+ "login required",
67
+ "authentication required",
68
+ "session expired",
69
+ "auth required",
70
+ "please authenticate",
71
+ )
72
+
73
+
74
+ def _is_auth_error(message: str) -> bool:
75
+ lowered = message.lower()
76
+ return any(signal in lowered for signal in _AUTH_ERROR_SIGNALS)
77
+
78
+
79
+ def decrypted_name(entry: dict) -> str | None:
80
+ """The decrypted filename of a `filesystem list` entry, or None if its name could
81
+ not be decrypted (``name.ok`` is false). Central helper so every consumer parses
82
+ the ``{"name": {"ok": bool, "value": str}}`` shape identically."""
83
+ name = entry.get("name", {})
84
+ if not name.get("ok"):
85
+ return None
86
+ return name.get("value")
87
+
88
+
89
+ class DriveClient:
90
+ def __init__(self, binary: str | None = None) -> None:
91
+ self._binary = binary or binary_path()
92
+
93
+ def _binary_available(self) -> bool:
94
+ return shutil.which(self._binary) is not None or Path(self._binary).exists()
95
+
96
+ def _invoke(self, args: list[str]) -> subprocess.CompletedProcess:
97
+ if not self._binary_available():
98
+ raise DriveError(f"proton-drive binary not found: {self._binary}")
99
+ return subprocess.run([self._binary, *args, "--json"], capture_output=True, text=True)
100
+
101
+ def _run_json(self, args: list[str]) -> dict | list:
102
+ result = self._invoke(args)
103
+ stdout = result.stdout.strip()
104
+ try:
105
+ parsed = json.loads(stdout) if stdout else {}
106
+ except json.JSONDecodeError as exc:
107
+ raise DriveError(f"unparseable output from proton-drive: {stdout!r}") from exc
108
+ if result.returncode != 0:
109
+ message = json.dumps(parsed) if parsed else result.stderr.strip()
110
+ if _is_auth_error(message):
111
+ raise DriveAuthError(f"proton-drive auth required: {message}")
112
+ raise DriveError(message)
113
+ return parsed
114
+
115
+ def _run_transfer(self, args: list[str]) -> TransferResult:
116
+ result = self._invoke(args)
117
+ stdout = result.stdout.strip()
118
+ try:
119
+ parsed = json.loads(stdout) if stdout else None
120
+ except json.JSONDecodeError:
121
+ parsed = None
122
+ if not isinstance(parsed, dict) or "transferredItems" not in parsed:
123
+ message = result.stderr.strip() or stdout
124
+ if _is_auth_error(message):
125
+ raise DriveAuthError(f"proton-drive auth required: {message}")
126
+ raise DriveError(message)
127
+ return TransferResult.from_json(parsed)
128
+
129
+ def version(self) -> str | None:
130
+ if not self._binary_available():
131
+ return None
132
+ result = subprocess.run([self._binary, "version"], capture_output=True, text=True)
133
+ if result.returncode != 0:
134
+ return None
135
+ return result.stdout.strip()
136
+
137
+ def is_authenticated(self) -> bool:
138
+ try:
139
+ self._run_json(["filesystem", "list", "/"])
140
+ except DriveError:
141
+ return False
142
+ return True
143
+
144
+ def list(self, remote_path: str) -> list[dict]:
145
+ result = self._run_json(["filesystem", "list", remote_path])
146
+ return result if isinstance(result, list) else []
147
+
148
+ def walk(self, remote_root: str) -> list[RemoteEntry]:
149
+ root = remote_root.rstrip("/")
150
+ results: list[RemoteEntry] = []
151
+ # queue of (absolute remote path, rel prefix); deque gives O(1) popleft
152
+ queue: deque[tuple[str, str]] = deque([(root, "")])
153
+ while queue:
154
+ abs_path, prefix = queue.popleft()
155
+ for entry in self.list(abs_path):
156
+ value = decrypted_name(entry)
157
+ if value is None:
158
+ logger.warning(
159
+ "skipping remote entry with undecryptable name under %s", abs_path
160
+ )
161
+ continue
162
+ rel = f"{prefix}{value}" if prefix else value
163
+ child_abs = f"{abs_path}/{value}"
164
+ if entry.get("type") == "folder":
165
+ results.append(RemoteEntry(rel_path=rel, is_dir=True, size=0))
166
+ queue.append((child_abs, f"{rel}/"))
167
+ else:
168
+ results.append(
169
+ RemoteEntry(
170
+ rel_path=rel,
171
+ is_dir=False,
172
+ size=entry.get("totalStorageSize", 0),
173
+ )
174
+ )
175
+ return results
176
+
177
+ def upload(
178
+ self,
179
+ local_paths: list[Path],
180
+ remote_parent: str,
181
+ file_strategy: str | None = None,
182
+ folder_strategy: str | None = None,
183
+ ) -> TransferResult:
184
+ args = ["filesystem", "upload"]
185
+ if file_strategy:
186
+ args += ["-f", file_strategy]
187
+ if folder_strategy:
188
+ args += ["-d", folder_strategy]
189
+ args += [str(p) for p in local_paths] + [remote_parent]
190
+ return self._run_transfer(args)
191
+
192
+ def download(
193
+ self,
194
+ remote_paths: list[str],
195
+ local_folder: Path,
196
+ file_strategy: str | None = None,
197
+ folder_strategy: str | None = None,
198
+ ) -> TransferResult:
199
+ args = ["filesystem", "download"]
200
+ if file_strategy:
201
+ args += ["-f", file_strategy]
202
+ if folder_strategy:
203
+ args += ["-d", folder_strategy]
204
+ args += remote_paths + [str(local_folder)]
205
+ return self._run_transfer(args)
206
+
207
+ def trash(self, remote_paths: list[str]) -> list[dict]:
208
+ result = self._run_json(["filesystem", "trash", *remote_paths])
209
+ return result if isinstance(result, list) else []
210
+
211
+ def restore(self, remote_paths: list[str]) -> list[dict]:
212
+ result = self._run_json(["filesystem", "restore", *remote_paths])
213
+ return result if isinstance(result, list) else []
214
+
215
+ def delete(self, remote_paths: list[str]) -> list[dict]:
216
+ result = self._run_json(["filesystem", "delete", *remote_paths])
217
+ return result if isinstance(result, list) else []
218
+
219
+ def create_folder(self, parent_path: str, name: str) -> dict:
220
+ result = self._run_json(["filesystem", "create-folder", parent_path, name])
221
+ return result if isinstance(result, dict) else {}
protonfs/ignore.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pathspec
6
+
7
+ IGNORE_FILE_NAME = "ignore"
8
+
9
+ DEFAULT_IGNORE_TEMPLATE = """\
10
+ # protonfs ignore patterns (gitignore syntax)
11
+ # Scopes what protonfs will sync from a given directory -- independent of
12
+ # the repo's own .gitignore.
13
+ *.tmp
14
+ *.swp
15
+ core.*
16
+ """
17
+
18
+
19
+ def ignore_path(repo_root: Path) -> Path:
20
+ return repo_root / ".protonfs" / IGNORE_FILE_NAME
21
+
22
+
23
+ def init_ignore(repo_root: Path) -> None:
24
+ path = ignore_path(repo_root)
25
+ if path.exists():
26
+ return
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ path.write_text(DEFAULT_IGNORE_TEMPLATE)
29
+
30
+
31
+ class IgnoreMatcher:
32
+ def __init__(self, patterns: list[str]) -> None:
33
+ self._spec = pathspec.GitIgnoreSpec.from_lines(patterns)
34
+
35
+ @classmethod
36
+ def from_file(cls, repo_root: Path) -> IgnoreMatcher:
37
+ path = ignore_path(repo_root)
38
+ if not path.exists():
39
+ return cls([])
40
+ return cls(path.read_text().splitlines())
41
+
42
+ def matches(self, rel_path: str) -> bool:
43
+ return self._spec.match_file(rel_path)
protonfs/index.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+ from pathlib import Path
6
+
7
+ INDEX_FILE_NAME = "index.json"
8
+
9
+
10
+ @dataclass
11
+ class IndexEntry:
12
+ size: int
13
+ mtime: float
14
+ sha256: str
15
+ remote_path: str
16
+ origin_device: str
17
+ local_state: str # "present" | "metadata-only"
18
+ last_synced: str # ISO-8601 timestamp
19
+
20
+ def to_dict(self) -> dict:
21
+ return asdict(self)
22
+
23
+ @classmethod
24
+ def from_dict(cls, data: dict) -> IndexEntry:
25
+ return cls(**data)
26
+
27
+
28
+ class IndexStore:
29
+ def __init__(self, repo_root: Path) -> None:
30
+ self._path = repo_root / ".protonfs" / INDEX_FILE_NAME
31
+ self._entries: dict[str, IndexEntry] = {}
32
+ self._load()
33
+
34
+ def _load(self) -> None:
35
+ if not self._path.exists():
36
+ return
37
+ raw = json.loads(self._path.read_text())
38
+ self._entries = {rel_path: IndexEntry.from_dict(data) for rel_path, data in raw.items()}
39
+
40
+ def save(self) -> None:
41
+ self._path.parent.mkdir(parents=True, exist_ok=True)
42
+ raw = {rel_path: entry.to_dict() for rel_path, entry in self._entries.items()}
43
+ self._path.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n")
44
+
45
+ def get(self, rel_path: str) -> IndexEntry | None:
46
+ return self._entries.get(rel_path)
47
+
48
+ def set(self, rel_path: str, entry: IndexEntry) -> None:
49
+ self._entries[rel_path] = entry
50
+
51
+ def remove(self, rel_path: str) -> None:
52
+ self._entries.pop(rel_path, None)
53
+
54
+ def all(self) -> dict[str, IndexEntry]:
55
+ return dict(self._entries)