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.
protonfs/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("protonfs")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0+unknown"
protonfs/batching.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ DEFAULT_BATCH_SIZE = 200
6
+
7
+
8
+ def group_by_parent(rel_paths: list[str]) -> dict[str, list[str]]:
9
+ groups: dict[str, list[str]] = {}
10
+ for rel in rel_paths:
11
+ parent = str(Path(rel).parent)
12
+ groups.setdefault(parent, []).append(rel)
13
+ return groups
14
+
15
+
16
+ def batches(items: list, size: int = DEFAULT_BATCH_SIZE) -> list[list]:
17
+ return [items[i : i + size] for i in range(0, len(items), size)]
protonfs/cli.py ADDED
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+
5
+ import click
6
+
7
+ from protonfs import __version__
8
+
9
+
10
+ def _drive_error_boundary(func):
11
+ """Wrap a command so DriveError/DriveAuthError become clean ClickExceptions.
12
+
13
+ Runtime commands call into the Drive CLI; a mid-run failure there (auth
14
+ expiry, network, missing path) would otherwise surface as a raw Python
15
+ traceback instead of a readable message.
16
+ """
17
+
18
+ @functools.wraps(func)
19
+ def wrapper(*args, **kwargs):
20
+ from protonfs.drive import DriveAuthError, DriveError
21
+
22
+ try:
23
+ return func(*args, **kwargs)
24
+ except DriveAuthError as exc:
25
+ raise click.ClickException(
26
+ f"{exc}\nRun `proton-drive auth login` to re-authenticate, "
27
+ "then retry this command."
28
+ ) from exc
29
+ except DriveError as exc:
30
+ raise click.ClickException(str(exc)) from exc
31
+
32
+ return wrapper
33
+
34
+
35
+ @click.group()
36
+ @click.version_option(__version__, prog_name="protonfs")
37
+ def main() -> None:
38
+ """Sync a local directory tree with Proton Drive."""
39
+
40
+
41
+ @main.command()
42
+ @click.option("--dry-run", is_flag=True, help="Preview the LFS migration without making changes.")
43
+ def setup(dry_run: bool) -> None:
44
+ """Install/verify the proton-drive CLI, init .protonfs/, migrate off git-lfs if present."""
45
+ from pathlib import Path
46
+
47
+ from protonfs.commands.setup import run_setup
48
+
49
+ run_setup(Path.cwd(), dry_run=dry_run)
50
+
51
+
52
+ @main.command()
53
+ @click.argument("path", required=False)
54
+ def status(path: str | None) -> None:
55
+ """Summarize sync state (counts by local-only/remote-only/synced/conflict)."""
56
+ from protonfs.commands.status import compute_status
57
+ from protonfs.context import load_context
58
+ from protonfs.diff import SyncState
59
+
60
+ ctx = load_context()
61
+ counts = compute_status(ctx, path)
62
+ for state in SyncState:
63
+ click.echo(f"{state.value}: {counts.get(state.value, 0)}")
64
+
65
+
66
+ @main.command()
67
+ @click.argument("path", required=False)
68
+ @click.option("--remote", is_flag=True, help="Force a live Drive listing instead of the index.")
69
+ @click.option("--trash", is_flag=True, help="List /trash instead.")
70
+ @_drive_error_boundary
71
+ def ls(path: str | None, remote: bool, trash: bool) -> None:
72
+ """List tracked files with their sync state."""
73
+ from rich.console import Console
74
+
75
+ from protonfs.commands.ls import render_ls
76
+ from protonfs.context import load_context
77
+
78
+ ctx = load_context()
79
+ render_ls(ctx, path, remote, trash, Console())
80
+
81
+
82
+ @main.command()
83
+ @click.argument("path", required=False)
84
+ @click.option("--resolve", type=click.Choice(["merge", "keep-both", "replace", "skip"]))
85
+ @click.option("--dry-run", is_flag=True)
86
+ @_drive_error_boundary
87
+ def push(path: str | None, resolve: str | None, dry_run: bool) -> None:
88
+ """Upload local-only/changed files to Drive."""
89
+ from protonfs.commands.push import push as push_files
90
+ from protonfs.context import load_context
91
+
92
+ ctx = load_context()
93
+ result = push_files(ctx, path, resolve, dry_run)
94
+ click.echo(
95
+ f"transferred={result.transferred_items} skipped={result.skipped_items} "
96
+ f"failed={result.failed_items}"
97
+ )
98
+ for failure in result.failures:
99
+ click.echo(f" FAILED {failure['name']}: {failure['error']}")
100
+ if result.failed_items:
101
+ if not resolve:
102
+ click.echo(
103
+ " -> these are remote conflicts; re-run with "
104
+ "--resolve=merge|keep-both|replace|skip to resolve them."
105
+ )
106
+ raise click.exceptions.Exit(1)
107
+
108
+
109
+ @main.command()
110
+ @click.argument("path", required=False)
111
+ @click.option("--resolve", type=click.Choice(["merge", "keep-both", "replace", "skip"]))
112
+ @click.option("--dry-run", is_flag=True)
113
+ @click.option(
114
+ "--refresh",
115
+ is_flag=True,
116
+ help="Discover remote files (seed the index) before pulling.",
117
+ )
118
+ @_drive_error_boundary
119
+ def pull(path: str | None, resolve: str | None, dry_run: bool, refresh: bool) -> None:
120
+ """Download remote-only/changed files from Drive."""
121
+ from protonfs.commands.pull import pull as pull_files
122
+ from protonfs.context import load_context
123
+
124
+ ctx = load_context()
125
+ if not refresh and not ctx.index.all():
126
+ click.echo("index empty; run `protonfs refresh` first (or `pull --refresh`)")
127
+ return
128
+ result = pull_files(ctx, path, resolve, dry_run, refresh=refresh)
129
+ click.echo(
130
+ f"transferred={result.transferred_items} skipped={result.skipped_items} "
131
+ f"failed={result.failed_items}"
132
+ )
133
+ for failure in result.failures:
134
+ click.echo(f" FAILED {failure['name']}: {failure['error']}")
135
+ if result.failed_items:
136
+ raise click.exceptions.Exit(1)
137
+
138
+
139
+ @main.command()
140
+ @click.argument("path")
141
+ @click.option("-r", "--recursive", is_flag=True)
142
+ @click.option("-f", "--force", is_flag=True, help="Permanently delete (trash, then delete).")
143
+ @click.option("--yes", is_flag=True, help="Skip confirmation prompt.")
144
+ @_drive_error_boundary
145
+ def rm(path: str, recursive: bool, force: bool, yes: bool) -> None:
146
+ """Remove a file/directory from Drive (trash by default, -f for permanent)."""
147
+ from protonfs.commands.rm import rm as rm_path
148
+ from protonfs.context import load_context
149
+
150
+ ctx = load_context()
151
+ rm_path(ctx, path, recursive, force, confirmed=yes)
152
+
153
+
154
+ @main.command()
155
+ @click.argument("path")
156
+ @_drive_error_boundary
157
+ def restore(path: str) -> None:
158
+ """Restore a trashed file/directory on Drive."""
159
+ from protonfs.commands.restore import restore as restore_path
160
+ from protonfs.context import load_context
161
+
162
+ ctx = load_context()
163
+ restore_path(ctx, path)
164
+
165
+
166
+ @main.command()
167
+ @click.argument("path", required=False)
168
+ @click.option("--prune", is_flag=True, help="Drop index entries for files deleted on the remote.")
169
+ @_drive_error_boundary
170
+ def refresh(path: str | None, prune: bool) -> None:
171
+ """Discover remote files and seed the local index (metadata-only)."""
172
+ from protonfs.commands.refresh import refresh as refresh_index
173
+ from protonfs.context import load_context
174
+
175
+ ctx = load_context()
176
+ result = refresh_index(ctx, path, prune)
177
+ click.echo(f"Discovered {result.seeded} new remote file(s) (metadata-only).")
178
+ if result.remote_changed:
179
+ click.echo(f" {result.remote_changed} file(s) changed on the remote (remote-changed):")
180
+ for p in result.changed_paths:
181
+ click.echo(f" {p}")
182
+ click.echo(
183
+ " -> `protonfs pull --resolve=replace <path>` to take the remote version, "
184
+ "or `protonfs push --resolve=replace <path>` to overwrite it with your local copy."
185
+ )
186
+ if result.remote_deleted:
187
+ verb = "pruned" if prune else "found"
188
+ click.echo(f" {result.remote_deleted} file(s) deleted on the remote ({verb}):")
189
+ for p in result.deleted_paths:
190
+ click.echo(f" {p}")
191
+ if not prune:
192
+ click.echo(" -> `protonfs refresh --prune` to drop them from your local index.")
193
+ if result.seeded:
194
+ click.echo(f"Run `protonfs pull` to download the {result.seeded} discovered file(s).")
195
+
196
+
197
+ @main.command("install-drive")
198
+ @click.option("--version", default=None, help="proton-drive version to install (default: pinned).")
199
+ def install_drive_cmd(version: str | None) -> None:
200
+ """Download and verify the official proton-drive CLI binary."""
201
+ from protonfs.install import InstallError, install_drive
202
+
203
+ try:
204
+ result = install_drive(version=version)
205
+ except InstallError as exc:
206
+ raise click.ClickException(str(exc)) from exc
207
+ click.echo(f"Installed proton-drive to {result.path} (SHA-512 verified).")
208
+ for warning in result.warnings:
209
+ click.echo(f" ! {warning}")
210
+ click.echo("Next: run `protonfs auth login` to authenticate.")
211
+
212
+
213
+ @main.command()
214
+ @click.argument("action", type=click.Choice(["login", "logout", "status"]))
215
+ @_drive_error_boundary
216
+ def auth(action: str) -> None:
217
+ """Authenticate the proton-drive CLI: login | logout | status (passthrough)."""
218
+ from protonfs.commands.auth import auth_passthrough
219
+
220
+ code = auth_passthrough(action)
221
+ if code != 0:
222
+ raise click.exceptions.Exit(code)
223
+
224
+
225
+ if __name__ == "__main__":
226
+ main()
File without changes
@@ -0,0 +1,36 @@
1
+ # src/protonfs/commands/auth.py
2
+ """`protonfs auth {login,logout,status}` — a thin passthrough to `proton-drive auth`.
3
+
4
+ Auth is left entirely to proton-drive (D3.3): it prints a URL to open on any
5
+ device and persists the session to the OS keyring, so this works headlessly with
6
+ no custom handling. We inherit stdio (no --json, no capture) so the interactive
7
+ login URL reaches the user's terminal.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import subprocess
13
+ from pathlib import Path
14
+
15
+ from protonfs.drive import DriveError, binary_path
16
+
17
+ AUTH_SUBCOMMANDS = ("login", "logout", "status")
18
+
19
+
20
+ def auth_passthrough(subcommand: str, binary: str | None = None, runner=subprocess.run) -> int:
21
+ """Invoke `proton-drive auth <subcommand>` with inherited stdio; return exit code.
22
+
23
+ Raises DriveError (rendered cleanly by the CLI error boundary) if the subcommand
24
+ is unknown or the proton-drive binary is not installed/on PATH -- so a first-time
25
+ user who runs `auth login` before `install-drive` gets an instructive message
26
+ instead of a raw FileNotFoundError.
27
+ """
28
+ if subcommand not in AUTH_SUBCOMMANDS:
29
+ raise ValueError(f"unknown auth subcommand: {subcommand!r}")
30
+ bin_path = binary or binary_path()
31
+ if shutil.which(bin_path) is None and not Path(bin_path).exists():
32
+ raise DriveError(
33
+ f"proton-drive binary not found: {bin_path}. Run `protonfs install-drive` first."
34
+ )
35
+ result = runner([bin_path, "auth", subcommand])
36
+ return result.returncode
@@ -0,0 +1,61 @@
1
+ # src/protonfs/commands/ls.py
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from protonfs.context import RepoContext
10
+ from protonfs.diff import classify, within_subpath
11
+ from protonfs.drive import decrypted_name
12
+ from protonfs.ignore import IgnoreMatcher
13
+ from protonfs.localscan import scan
14
+
15
+
16
+ def remote_rel_paths(ctx: RepoContext, subpath: str | None = None) -> dict[str, int]:
17
+ """Recursive files-only remote listing, scoped to `subpath` when given. rel_paths
18
+ are re-prefixed with the subpath so they match the index's repo-root-relative keys
19
+ (same convention as `refresh`)."""
20
+ remote_root = ctx.config.remote_root
21
+ if subpath:
22
+ remote_root = f"{remote_root}/{subpath}"
23
+ result = {e.rel_path: e.size for e in ctx.drive.walk(remote_root) if not e.is_dir}
24
+ if subpath:
25
+ result = {f"{subpath}/{rel}": size for rel, size in result.items()}
26
+ return result
27
+
28
+
29
+ def render_ls(
30
+ ctx: RepoContext,
31
+ subpath: str | None,
32
+ remote: bool,
33
+ trash: bool,
34
+ console: Console,
35
+ ) -> None:
36
+ if trash:
37
+ entries = ctx.drive.list("/trash")
38
+ table = Table("name", "type")
39
+ for entry in entries:
40
+ name_val = decrypted_name(entry)
41
+ if name_val is not None:
42
+ table.add_row(name_val, entry.get("type", ""))
43
+ console.print(table)
44
+ return
45
+
46
+ ignore = IgnoreMatcher.from_file(ctx.root)
47
+ scan_root = Path(subpath) if subpath else Path(".")
48
+ local = scan(ctx.root, scan_root, ignore, ctx.index, low_io=ctx.config.defaults.low_io)
49
+ remote_map = remote_rel_paths(ctx, subpath) if remote else None
50
+ # classify reasons over the whole repo-wide index; when a subpath was given, the
51
+ # local scan and remote walk are scoped to it, so restrict the rows to that
52
+ # subpath too -- otherwise out-of-scope index entries (never scanned/walked) show
53
+ # up, and with a remote view are misread as remote-deleted.
54
+ diff_entries = [
55
+ e for e in classify(local, ctx.index, remote_map) if within_subpath(e.rel_path, subpath)
56
+ ]
57
+
58
+ table = Table("path", "state")
59
+ for entry in diff_entries:
60
+ table.add_row(entry.rel_path, entry.state.value)
61
+ console.print(table)
@@ -0,0 +1,85 @@
1
+ # src/protonfs/commands/pull.py
2
+ from __future__ import annotations
3
+
4
+ import datetime
5
+ from pathlib import Path
6
+
7
+ from protonfs.batching import batches, group_by_parent
8
+ from protonfs.context import RepoContext
9
+ from protonfs.diff import SyncState, classify
10
+ from protonfs.drive import TransferResult
11
+ from protonfs.ignore import IgnoreMatcher
12
+ from protonfs.index import IndexEntry
13
+ from protonfs.localscan import hash_file, scan
14
+
15
+
16
+ def pull(
17
+ ctx: RepoContext,
18
+ subpath: str | None,
19
+ resolve: str | None,
20
+ dry_run: bool,
21
+ refresh: bool = False,
22
+ ) -> TransferResult:
23
+ ignore = IgnoreMatcher.from_file(ctx.root)
24
+ scan_root = Path(subpath) if subpath else Path(".")
25
+ local = scan(ctx.root, scan_root, ignore, ctx.index, low_io=ctx.config.defaults.low_io)
26
+ if refresh:
27
+ from protonfs.commands.refresh import refresh as refresh_index
28
+
29
+ # Reuse the scan we just did; on a dry run seed only in memory so the preview
30
+ # is accurate without persisting metadata-only entries to index.json.
31
+ refresh_index(ctx, subpath, prune=False, persist=not dry_run, local=local)
32
+ diff_entries = classify(local, ctx.index)
33
+
34
+ to_pull = [
35
+ e.rel_path
36
+ for e in diff_entries
37
+ if e.state in (SyncState.REMOTE_ONLY, SyncState.METADATA_ONLY)
38
+ ]
39
+ if dry_run or not to_pull:
40
+ return TransferResult(len(to_pull), 0, 0, [])
41
+
42
+ strategy = resolve or ctx.config.defaults.on_conflict
43
+ groups = group_by_parent(to_pull)
44
+ total = TransferResult(0, 0, 0, [])
45
+ now = datetime.datetime.now(datetime.timezone.utc).isoformat()
46
+
47
+ for parent, rels in groups.items():
48
+ local_folder = ctx.root if parent == "." else ctx.root / parent
49
+ local_folder.mkdir(parents=True, exist_ok=True)
50
+ for batch in batches(rels):
51
+ remote_paths = []
52
+ for rel in batch:
53
+ entry = ctx.index.get(rel)
54
+ default_remote = f"{ctx.config.remote_root}/{rel}"
55
+ remote_paths.append(entry.remote_path if entry else default_remote)
56
+ result = ctx.drive.download(remote_paths, local_folder, file_strategy=strategy)
57
+ total.transferred_items += result.transferred_items
58
+ total.skipped_items += result.skipped_items
59
+ total.failed_items += result.failed_items
60
+ total.failures += result.failures
61
+
62
+ failed_names = {f["name"] for f in result.failures}
63
+ for rel in batch:
64
+ if Path(rel).name in failed_names:
65
+ continue
66
+ downloaded_path = ctx.root / rel
67
+ if not downloaded_path.exists():
68
+ continue
69
+ stat = downloaded_path.stat()
70
+ prior = ctx.index.get(rel)
71
+ default_remote = f"{ctx.config.remote_root}/{rel}"
72
+ ctx.index.set(
73
+ rel,
74
+ IndexEntry(
75
+ size=stat.st_size,
76
+ mtime=stat.st_mtime,
77
+ sha256=hash_file(downloaded_path),
78
+ remote_path=prior.remote_path if prior else default_remote,
79
+ origin_device=prior.origin_device if prior else "unknown",
80
+ local_state="present",
81
+ last_synced=now,
82
+ ),
83
+ )
84
+ ctx.index.save()
85
+ return total
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ from pathlib import Path
5
+
6
+ from protonfs.batching import batches, group_by_parent
7
+ from protonfs.context import RepoContext
8
+ from protonfs.diff import SyncState, classify
9
+ from protonfs.drive import DriveError, TransferResult
10
+ from protonfs.ignore import IgnoreMatcher
11
+ from protonfs.index import IndexEntry
12
+ from protonfs.localscan import scan
13
+
14
+
15
+ def _ensure_remote_dir(ctx: RepoContext, remote_dir: str) -> None:
16
+ root = ctx.config.remote_root.rstrip("/")
17
+ if not remote_dir.startswith(root):
18
+ return
19
+ relative = remote_dir[len(root) :].strip("/")
20
+ if not relative:
21
+ return
22
+ current = root
23
+ for segment in relative.split("/"):
24
+ try:
25
+ ctx.drive.create_folder(current, segment)
26
+ except DriveError:
27
+ pass # already exists -- a real failure will surface on the upload call below
28
+ current = f"{current}/{segment}"
29
+
30
+
31
+ def push(
32
+ ctx: RepoContext,
33
+ subpath: str | None,
34
+ resolve: str | None,
35
+ dry_run: bool,
36
+ ) -> TransferResult:
37
+ ignore = IgnoreMatcher.from_file(ctx.root)
38
+ scan_root = Path(subpath) if subpath else Path(".")
39
+ local = scan(ctx.root, scan_root, ignore, ctx.index, low_io=ctx.config.defaults.low_io)
40
+ diff_entries = classify(local, ctx.index)
41
+
42
+ to_push = [
43
+ e.rel_path for e in diff_entries if e.state in (SyncState.LOCAL_ONLY, SyncState.CONFLICT)
44
+ ]
45
+ if dry_run or not to_push:
46
+ return TransferResult(len(to_push), 0, 0, [])
47
+
48
+ # D2.1: default push applies NO conflict strategy so the CLI surfaces conflicts as
49
+ # named per-file failures (never a silent skip that we'd falsely index). A strategy
50
+ # is used only when the user explicitly asks via --resolve.
51
+ strategy = resolve
52
+ groups = group_by_parent(to_push)
53
+ total = TransferResult(0, 0, 0, [])
54
+ now = datetime.datetime.now(datetime.timezone.utc).isoformat()
55
+
56
+ for parent, rels in groups.items():
57
+ remote_parent = (
58
+ f"{ctx.config.remote_root}/{parent}" if parent != "." else ctx.config.remote_root
59
+ )
60
+ _ensure_remote_dir(ctx, remote_parent)
61
+ for batch in batches(rels):
62
+ local_paths = [ctx.root / rel for rel in batch]
63
+ result = ctx.drive.upload(local_paths, remote_parent, file_strategy=strategy)
64
+ total.transferred_items += result.transferred_items
65
+ total.skipped_items += result.skipped_items
66
+ total.failed_items += result.failed_items
67
+ total.failures += result.failures
68
+
69
+ # D2.1: a skip is reported only as an aggregate count, so we cannot tell
70
+ # WHICH files in the batch were skipped. Rather than falsely record an
71
+ # unconfirmed hash, index none of this batch's non-failed files and leave
72
+ # them for the next push.
73
+ if result.skipped_items > 0:
74
+ continue
75
+ failed_names = {f["name"] for f in result.failures}
76
+ for rel in batch:
77
+ if Path(rel).name in failed_names:
78
+ continue
79
+ entry = local[rel]
80
+ remote_path = f"{remote_parent}/{Path(rel).name}"
81
+ ctx.index.set(
82
+ rel,
83
+ IndexEntry(
84
+ size=entry.size,
85
+ mtime=entry.mtime,
86
+ sha256=entry.sha256,
87
+ remote_path=remote_path,
88
+ origin_device=ctx.config.device_id,
89
+ local_state="present",
90
+ last_synced=now,
91
+ ),
92
+ )
93
+ ctx.index.save()
94
+ return total
@@ -0,0 +1,86 @@
1
+ # src/protonfs/commands/refresh.py
2
+ from __future__ import annotations
3
+
4
+ import datetime
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from protonfs.context import RepoContext
9
+ from protonfs.diff import SyncState, classify, within_subpath
10
+ from protonfs.ignore import IgnoreMatcher
11
+ from protonfs.index import IndexEntry
12
+ from protonfs.localscan import ScanEntry, scan
13
+
14
+
15
+ @dataclass
16
+ class RefreshResult:
17
+ seeded: int = 0
18
+ remote_changed: int = 0
19
+ remote_deleted: int = 0
20
+ pruned: int = 0
21
+ changed_paths: list[str] = field(default_factory=list)
22
+ deleted_paths: list[str] = field(default_factory=list)
23
+
24
+
25
+ def refresh(
26
+ ctx: RepoContext,
27
+ subpath: str | None,
28
+ prune: bool,
29
+ persist: bool = True,
30
+ local: dict[str, ScanEntry] | None = None,
31
+ ) -> RefreshResult:
32
+ remote_root = ctx.config.remote_root
33
+ if subpath:
34
+ remote_root = f"{remote_root}/{subpath}"
35
+ entries = ctx.drive.walk(remote_root)
36
+ remote = {e.rel_path: e.size for e in entries if not e.is_dir}
37
+ # rel_paths from the walk are relative to remote_root; if a subpath was given,
38
+ # re-prefix so keys match the index's repo-root-relative rel_paths.
39
+ if subpath:
40
+ remote = {f"{subpath}/{rel}": size for rel, size in remote.items()}
41
+
42
+ # `local` may be supplied by a caller (e.g. pull --refresh) that already scanned
43
+ # the same tree, so we don't pay for a second recursive walk + re-hash.
44
+ if local is None:
45
+ ignore = IgnoreMatcher.from_file(ctx.root)
46
+ scan_root = Path(subpath) if subpath else Path(".")
47
+ local = scan(ctx.root, scan_root, ignore, ctx.index, low_io=ctx.config.defaults.low_io)
48
+ diff_entries = classify(local, ctx.index, remote)
49
+
50
+ result = RefreshResult()
51
+ now = datetime.datetime.now(datetime.timezone.utc).isoformat()
52
+
53
+ for rel, size in remote.items():
54
+ if ctx.index.get(rel) is None and rel not in local:
55
+ ctx.index.set(
56
+ rel,
57
+ IndexEntry(
58
+ size=size,
59
+ mtime=0.0,
60
+ sha256="",
61
+ remote_path=f"{ctx.config.remote_root}/{rel}",
62
+ origin_device="unknown",
63
+ local_state="metadata-only",
64
+ last_synced=now,
65
+ ),
66
+ )
67
+ result.seeded += 1
68
+
69
+ for entry in diff_entries:
70
+ # A subpath-scoped walk only saw files under `subpath`; index entries outside
71
+ # it were never checked, so their absence from `remote` is not a deletion.
72
+ if not within_subpath(entry.rel_path, subpath):
73
+ continue
74
+ if entry.state == SyncState.REMOTE_CHANGED:
75
+ result.remote_changed += 1
76
+ result.changed_paths.append(entry.rel_path)
77
+ elif entry.state == SyncState.REMOTE_DELETED:
78
+ result.remote_deleted += 1
79
+ result.deleted_paths.append(entry.rel_path)
80
+ if prune:
81
+ ctx.index.remove(entry.rel_path)
82
+ result.pruned += 1
83
+
84
+ if persist:
85
+ ctx.index.save()
86
+ return result
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from protonfs.context import RepoContext
4
+
5
+
6
+ def restore(ctx: RepoContext, rel_path: str) -> None:
7
+ entry = ctx.index.get(rel_path)
8
+ remote_path = entry.remote_path if entry else f"{ctx.config.remote_root}/{rel_path}"
9
+ ctx.drive.restore([remote_path])
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import PurePosixPath
4
+
5
+ import click
6
+
7
+ from protonfs.context import RepoContext
8
+ from protonfs.drive import decrypted_name
9
+
10
+
11
+ def rm(ctx: RepoContext, rel_path: str, recursive: bool, force: bool, confirmed: bool) -> None:
12
+ local_target = ctx.root / rel_path
13
+ if local_target.is_dir() and not recursive:
14
+ raise click.ClickException(
15
+ f"'{rel_path}' is a directory; pass -r/--recursive to remove it."
16
+ )
17
+
18
+ if not confirmed:
19
+ kind = "permanently delete" if force else "trash"
20
+ click.confirm(f"{kind.capitalize()} '{rel_path}' on Drive?", abort=True)
21
+
22
+ entry = ctx.index.get(rel_path)
23
+ remote_path = entry.remote_path if entry else f"{ctx.config.remote_root}/{rel_path}"
24
+
25
+ ctx.drive.trash([remote_path])
26
+ if force:
27
+ # D2.2: permanent delete works only against /trash/<basename>, and with
28
+ # duplicate basenames the CLI deletes one arbitrarily. So only delete when
29
+ # exactly one trashed item carries this basename; otherwise leave it trashed
30
+ # (still reversible) and tell the user to resolve it manually.
31
+ name = PurePosixPath(remote_path).name
32
+ matches = [
33
+ entry for entry in ctx.drive.list("/trash") if decrypted_name(entry) == name
34
+ ]
35
+ if len(matches) == 1:
36
+ ctx.drive.delete([f"/trash/{name}"])
37
+ elif len(matches) > 1:
38
+ click.echo(
39
+ f"{len(matches)} items named '{name}' are in trash; protonfs can't "
40
+ f"safely pick yours for permanent deletion. Resolve it via the Proton "
41
+ f"Drive app/web, or leave it trashed (it is already reversible)."
42
+ )
43
+ else:
44
+ click.echo(
45
+ f"'{name}' was trashed but could not be found in trash for permanent "
46
+ f"deletion (it may still be processing); it remains trashed and reversible."
47
+ )
48
+
49
+ for indexed_rel in list(ctx.index.all()):
50
+ if indexed_rel == rel_path or indexed_rel.startswith(rel_path + "/"):
51
+ ctx.index.remove(indexed_rel)
52
+ ctx.index.save()