git-ftp 2.0.0.dev0__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.
- git_ftp-2.0.0.dev0.dist-info/METADATA +243 -0
- git_ftp-2.0.0.dev0.dist-info/RECORD +47 -0
- git_ftp-2.0.0.dev0.dist-info/WHEEL +4 -0
- git_ftp-2.0.0.dev0.dist-info/entry_points.txt +2 -0
- git_ftp-2.0.0.dev0.dist-info/licenses/LICENSE +674 -0
- gitftp/__init__.py +5 -0
- gitftp/__main__.py +7 -0
- gitftp/_version.py +24 -0
- gitftp/auth.py +209 -0
- gitftp/changeset.py +86 -0
- gitftp/cli/__init__.py +111 -0
- gitftp/cli/cmd_add_scope.py +23 -0
- gitftp/cli/cmd_catchup.py +19 -0
- gitftp/cli/cmd_download.py +19 -0
- gitftp/cli/cmd_help.py +30 -0
- gitftp/cli/cmd_init.py +19 -0
- gitftp/cli/cmd_log.py +31 -0
- gitftp/cli/cmd_pull.py +19 -0
- gitftp/cli/cmd_push.py +21 -0
- gitftp/cli/cmd_remove_scope.py +23 -0
- gitftp/cli/cmd_show.py +31 -0
- gitftp/cli/cmd_snapshot.py +20 -0
- gitftp/cli/cmd_unlock.py +19 -0
- gitftp/cli/cmd_version.py +19 -0
- gitftp/cli/group.py +68 -0
- gitftp/cli/options.py +208 -0
- gitftp/config.py +170 -0
- gitftp/deploy.py +341 -0
- gitftp/errors.py +86 -0
- gitftp/gitrepo.py +293 -0
- gitftp/hooks.py +30 -0
- gitftp/ignore.py +83 -0
- gitftp/include.py +94 -0
- gitftp/lock.py +94 -0
- gitftp/mirror.py +413 -0
- gitftp/options.py +72 -0
- gitftp/output.py +132 -0
- gitftp/session.py +212 -0
- gitftp/transfer.py +269 -0
- gitftp/transport/__init__.py +1 -0
- gitftp/transport/base.py +93 -0
- gitftp/transport/curlftp.py +389 -0
- gitftp/transport/listing.py +155 -0
- gitftp/transport/registry.py +73 -0
- gitftp/transport/sftp.py +442 -0
- gitftp/url.py +204 -0
- gitftp/version.py +35 -0
gitftp/mirror.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Native download / pull / snapshot (upstream used ``lftp mirror``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from gitftp import deploy
|
|
11
|
+
from gitftp.changeset import remote_path
|
|
12
|
+
from gitftp.errors import DownloadError, FilesystemError, GitError, UsageError
|
|
13
|
+
from gitftp.gitrepo import GitRepo
|
|
14
|
+
from gitftp.ignore import IgnoreRules
|
|
15
|
+
from gitftp.lock import LOCK_FILE, RemoteLock
|
|
16
|
+
from gitftp.options import CliOptions
|
|
17
|
+
from gitftp.output import Output
|
|
18
|
+
from gitftp.session import Session, open_session
|
|
19
|
+
from gitftp.transfer import DownloadTask, TransferError, TransferPool
|
|
20
|
+
from gitftp.transport import registry
|
|
21
|
+
from gitftp.transport.base import Entry, RemoteNotFound, Transport
|
|
22
|
+
|
|
23
|
+
PROTECTED = frozenset({".git", ".git-ftp-ignore", ".git-ftp-include", ".git-ftp-config", LOCK_FILE})
|
|
24
|
+
PART_SUFFIX = ".git-ftp-part"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class MirrorOptions:
|
|
29
|
+
dry_run: bool = False
|
|
30
|
+
changed_only: bool = False
|
|
31
|
+
lock: bool = False
|
|
32
|
+
force: bool = False
|
|
33
|
+
no_commit: bool = False
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_cli(cls, opts: CliOptions) -> MirrorOptions:
|
|
37
|
+
return cls(
|
|
38
|
+
dry_run=opts.dry_run,
|
|
39
|
+
changed_only=opts.changed_only,
|
|
40
|
+
lock=opts.lock,
|
|
41
|
+
force=opts.force,
|
|
42
|
+
no_commit=opts.no_commit,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class MirrorPlan:
|
|
48
|
+
downloads: list[DownloadTask] = field(default_factory=list)
|
|
49
|
+
local_deletes: list[Path] = field(default_factory=list)
|
|
50
|
+
mkdirs: list[Path] = field(default_factory=list)
|
|
51
|
+
probes: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# -- scanning -----------------------------------------------------------------
|
|
55
|
+
def scan_remote(pool: TransferPool, out: Output) -> dict[str, Entry]:
|
|
56
|
+
"""Recursive listing: ``rel`` -> Entry, directories keyed with a trailing '/'."""
|
|
57
|
+
result: dict[str, Entry] = {}
|
|
58
|
+
level = [""]
|
|
59
|
+
while level:
|
|
60
|
+
|
|
61
|
+
def list_one(t: Transport, d: str) -> list[Entry]:
|
|
62
|
+
return t.list_dir(d)
|
|
63
|
+
|
|
64
|
+
listings = pool.map(list_one, level, fail_fast=True, label=lambda d: d or "/")
|
|
65
|
+
next_level: list[str] = []
|
|
66
|
+
for directory, entries in zip(level, listings, strict=True):
|
|
67
|
+
if not isinstance(entries, list):
|
|
68
|
+
continue
|
|
69
|
+
for e in entries:
|
|
70
|
+
rel = f"{directory}{e.name}"
|
|
71
|
+
if e.is_dir:
|
|
72
|
+
result[rel + "/"] = e
|
|
73
|
+
next_level.append(rel + "/")
|
|
74
|
+
else:
|
|
75
|
+
result[rel] = e
|
|
76
|
+
level = next_level
|
|
77
|
+
out.debug(f"Listed {len(result)} remote entries so far.")
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def scan_local(base: Path) -> dict[str, os.stat_result]:
|
|
82
|
+
result: dict[str, os.stat_result] = {}
|
|
83
|
+
if not base.is_dir():
|
|
84
|
+
return result
|
|
85
|
+
for dirpath, dirnames, filenames in os.walk(base):
|
|
86
|
+
dirnames[:] = [d for d in dirnames if d != ".git"]
|
|
87
|
+
rel_dir = Path(dirpath).relative_to(base).as_posix()
|
|
88
|
+
prefix = "" if rel_dir == "." else rel_dir + "/"
|
|
89
|
+
for d in dirnames:
|
|
90
|
+
result[f"{prefix}{d}/"] = os.lstat(Path(dirpath, d))
|
|
91
|
+
for f in filenames:
|
|
92
|
+
result[f"{prefix}{f}"] = os.lstat(Path(dirpath, f))
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _excluded(rel: str, syncroot: str, ignore: IgnoreRules, deployed_file: str) -> bool:
|
|
97
|
+
parts = rel.strip("/").split("/")
|
|
98
|
+
if any(p == ".git" for p in parts):
|
|
99
|
+
return True
|
|
100
|
+
if rel in (deployed_file, deployed_file + "/"):
|
|
101
|
+
return True
|
|
102
|
+
if parts[-1] in PROTECTED and len(parts) == 1:
|
|
103
|
+
return True
|
|
104
|
+
if parts[-1].endswith(PART_SUFFIX):
|
|
105
|
+
return True
|
|
106
|
+
candidate = rel.rstrip("/")
|
|
107
|
+
return ignore.matches(syncroot + candidate) or ignore.matches(candidate)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_plan(
|
|
111
|
+
remote: dict[str, Entry],
|
|
112
|
+
local: dict[str, os.stat_result],
|
|
113
|
+
*,
|
|
114
|
+
base: Path,
|
|
115
|
+
syncroot: str,
|
|
116
|
+
ignore: IgnoreRules,
|
|
117
|
+
deployed_file: str,
|
|
118
|
+
only: set[str] | None,
|
|
119
|
+
allow_delete: bool,
|
|
120
|
+
protected: set[str] | None = None,
|
|
121
|
+
) -> MirrorPlan:
|
|
122
|
+
plan = MirrorPlan()
|
|
123
|
+
protected = protected or set()
|
|
124
|
+
conflicts: list[Path] = []
|
|
125
|
+
for rel, entry in remote.items():
|
|
126
|
+
if _excluded(rel, syncroot, ignore, deployed_file):
|
|
127
|
+
continue
|
|
128
|
+
if entry.is_dir:
|
|
129
|
+
if rel not in local:
|
|
130
|
+
if rel.rstrip("/") in local:
|
|
131
|
+
conflicts.append(base / rel.rstrip("/"))
|
|
132
|
+
plan.mkdirs.append(base / rel.rstrip("/"))
|
|
133
|
+
continue
|
|
134
|
+
if only is not None and rel not in only:
|
|
135
|
+
continue
|
|
136
|
+
st = local.get(rel)
|
|
137
|
+
if rel + "/" in local:
|
|
138
|
+
conflicts.append(base / rel)
|
|
139
|
+
st = None
|
|
140
|
+
task = DownloadTask(
|
|
141
|
+
remote=rel, local=base / rel, size=entry.size, mtime=entry.mtime, label=rel
|
|
142
|
+
)
|
|
143
|
+
if st is None or (entry.size is not None and entry.size != st.st_size):
|
|
144
|
+
plan.downloads.append(task)
|
|
145
|
+
elif entry.mtime is not None and entry.mtime_exact:
|
|
146
|
+
if entry.mtime > int(st.st_mtime) + 1:
|
|
147
|
+
plan.downloads.append(task)
|
|
148
|
+
else:
|
|
149
|
+
plan.probes.append(rel)
|
|
150
|
+
if allow_delete:
|
|
151
|
+
for rel in local:
|
|
152
|
+
if _excluded(rel, syncroot, ignore, deployed_file):
|
|
153
|
+
continue
|
|
154
|
+
if only is not None or rel.rstrip("/") in protected:
|
|
155
|
+
continue
|
|
156
|
+
if rel in remote:
|
|
157
|
+
continue
|
|
158
|
+
if rel.endswith("/"):
|
|
159
|
+
# Only delete a directory when it does not exist remotely at all.
|
|
160
|
+
plan.local_deletes.append(base / rel.rstrip("/"))
|
|
161
|
+
elif rel + "/" in remote:
|
|
162
|
+
continue # handled as a conflict
|
|
163
|
+
else:
|
|
164
|
+
plan.local_deletes.append(base / rel)
|
|
165
|
+
plan.local_deletes = conflicts + plan.local_deletes
|
|
166
|
+
plan.downloads.sort(key=lambda t: t.remote)
|
|
167
|
+
plan.mkdirs.sort()
|
|
168
|
+
return plan
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _safe_remove(target: Path, base: Path) -> None:
|
|
172
|
+
resolved = target.resolve()
|
|
173
|
+
root = base.resolve()
|
|
174
|
+
if resolved == root or root not in resolved.parents:
|
|
175
|
+
raise FilesystemError(f"Refusing to delete '{target}' outside of '{base}'.")
|
|
176
|
+
if ".git" in resolved.relative_to(root).parts:
|
|
177
|
+
raise FilesystemError(f"Refusing to delete '{target}' inside .git.")
|
|
178
|
+
if target.is_dir() and not target.is_symlink():
|
|
179
|
+
shutil.rmtree(target)
|
|
180
|
+
else:
|
|
181
|
+
target.unlink()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def apply_plan(
|
|
185
|
+
plan: MirrorPlan, pool: TransferPool, base: Path, out: Output, dry_run: bool
|
|
186
|
+
) -> None:
|
|
187
|
+
if dry_run:
|
|
188
|
+
for p in plan.local_deletes:
|
|
189
|
+
out.info(f"Would delete local '{p.relative_to(base).as_posix()}'.")
|
|
190
|
+
for p in plan.mkdirs:
|
|
191
|
+
out.info(f"Would create directory '{p.relative_to(base).as_posix()}'.")
|
|
192
|
+
for t in plan.downloads:
|
|
193
|
+
out.info(f"Would download '{t.label}'.")
|
|
194
|
+
return
|
|
195
|
+
deleted = 0
|
|
196
|
+
seen: set[Path] = set()
|
|
197
|
+
for p in sorted(plan.local_deletes, key=lambda x: (-len(x.parts), str(x))):
|
|
198
|
+
if p in seen or not (p.exists() or p.is_symlink()):
|
|
199
|
+
continue
|
|
200
|
+
seen.add(p)
|
|
201
|
+
_safe_remove(p, base)
|
|
202
|
+
deleted += 1
|
|
203
|
+
for d in plan.mkdirs:
|
|
204
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
if plan.downloads:
|
|
206
|
+
try:
|
|
207
|
+
pool.download(plan.downloads)
|
|
208
|
+
except TransferError as e:
|
|
209
|
+
raise DownloadError(f"Could not download files. {e}") from e
|
|
210
|
+
out.info(f"Downloaded {len(plan.downloads)} file(s), deleted {deleted} local file(s).")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def download_remote_updates(session: Session, opts: MirrorOptions, only: set[str] | None) -> None:
|
|
214
|
+
repo = session.require_repo()
|
|
215
|
+
out = session.out
|
|
216
|
+
base = repo.root / session.syncroot if session.syncroot else repo.root
|
|
217
|
+
ignore = IgnoreRules.load(repo.root)
|
|
218
|
+
with TransferPool(session.connect, session.jobs, out, primary=session.primary) as pool:
|
|
219
|
+
out.debug(f"Mirroring {session.url.name()}")
|
|
220
|
+
try:
|
|
221
|
+
remote = scan_remote(pool, out)
|
|
222
|
+
except TransferError as e:
|
|
223
|
+
if isinstance(e.cause, RemoteNotFound):
|
|
224
|
+
raise DownloadError(
|
|
225
|
+
f"Remote directory '{session.url.display()}' does not exist."
|
|
226
|
+
) from e
|
|
227
|
+
raise DownloadError(f"Could not list '{session.url.display()}'. {e.cause}") from e
|
|
228
|
+
local = scan_local(base)
|
|
229
|
+
# Local files git ignores (build output, secrets) are never deleted by a mirror.
|
|
230
|
+
git_ignored = repo.ignored([f"{session.syncroot}{rel.rstrip('/')}" for rel in local])
|
|
231
|
+
plan = build_plan(
|
|
232
|
+
remote,
|
|
233
|
+
local,
|
|
234
|
+
base=base,
|
|
235
|
+
syncroot=session.syncroot,
|
|
236
|
+
ignore=ignore,
|
|
237
|
+
deployed_file=session.deployed_sha1_file,
|
|
238
|
+
only=only,
|
|
239
|
+
allow_delete=not opts.changed_only,
|
|
240
|
+
protected={p[len(session.syncroot) :] for p in git_ignored},
|
|
241
|
+
)
|
|
242
|
+
if plan.probes:
|
|
243
|
+
|
|
244
|
+
def probe(t: Transport, rel: str) -> Entry | None:
|
|
245
|
+
return t.stat(rel)
|
|
246
|
+
|
|
247
|
+
stats = pool.map(probe, plan.probes, fail_fast=True, label=str)
|
|
248
|
+
for rel, st in zip(plan.probes, stats, strict=True):
|
|
249
|
+
if not isinstance(st, Entry) or st.mtime is None:
|
|
250
|
+
continue
|
|
251
|
+
lst = local[rel]
|
|
252
|
+
if st.mtime > int(lst.st_mtime) + 1:
|
|
253
|
+
plan.downloads.append(
|
|
254
|
+
DownloadTask(
|
|
255
|
+
remote=rel, local=base / rel, size=st.size, mtime=st.mtime, label=rel
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
plan.downloads.sort(key=lambda t: t.remote)
|
|
259
|
+
apply_plan(plan, pool, base, out, opts.dry_run)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _deployed_sha(session: Session) -> str:
|
|
263
|
+
try:
|
|
264
|
+
data = session.primary.get(session.deployed_sha1_file)
|
|
265
|
+
except RemoteNotFound as e:
|
|
266
|
+
raise DownloadError(
|
|
267
|
+
"Could not get last commit. Use 'git ftp init' for the initial push."
|
|
268
|
+
) from e
|
|
269
|
+
sha = data.decode("utf-8", "replace").strip()
|
|
270
|
+
if not sha:
|
|
271
|
+
raise DownloadError("Could not get last commit. Use 'git ftp init' for the initial push.")
|
|
272
|
+
return sha
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _changed_only(repo: GitRepo, session: Session, a: str, b: str | None) -> set[str]:
|
|
276
|
+
names = repo.diff_names_between(a, b)
|
|
277
|
+
only = {remote_path(n, session.syncroot) for n in names}
|
|
278
|
+
session.out.debug("Only pulling diff files:\n" + "\n".join(sorted(only)))
|
|
279
|
+
return only
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# -- actions ------------------------------------------------------------------
|
|
283
|
+
def run_download(session: Session, opts: MirrorOptions) -> None:
|
|
284
|
+
repo = session.require_repo()
|
|
285
|
+
registry.check_available(session.url.scheme)
|
|
286
|
+
if repo.is_dirty():
|
|
287
|
+
raise GitError("Dirty repository: Having uncommitted changes. Exiting...")
|
|
288
|
+
if repo.has_any_changes():
|
|
289
|
+
raise GitError("Dirty repository: Having untracked files. Exiting...")
|
|
290
|
+
local = repo.head_sha()
|
|
291
|
+
only = None
|
|
292
|
+
if opts.changed_only:
|
|
293
|
+
only = _changed_only(repo, session, _deployed_sha(session), None)
|
|
294
|
+
lock = RemoteLock(
|
|
295
|
+
session.primary,
|
|
296
|
+
local,
|
|
297
|
+
enabled=opts.lock,
|
|
298
|
+
force=opts.force,
|
|
299
|
+
dry_run=opts.dry_run,
|
|
300
|
+
out=session.out,
|
|
301
|
+
)
|
|
302
|
+
lock.acquire()
|
|
303
|
+
try:
|
|
304
|
+
download_remote_updates(session, opts, only)
|
|
305
|
+
finally:
|
|
306
|
+
lock.release()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def run_pull(session: Session, opts: MirrorOptions) -> None:
|
|
310
|
+
repo = session.require_repo()
|
|
311
|
+
out = session.out
|
|
312
|
+
registry.check_available(session.url.scheme)
|
|
313
|
+
if repo.is_dirty():
|
|
314
|
+
raise GitError("Dirty repository: Having uncommitted changes. Exiting...")
|
|
315
|
+
current = repo.current_branch()
|
|
316
|
+
deployed = _deployed_sha(session)
|
|
317
|
+
out.debug(f"Last deployed SHA1 for {session.url.name()} is {deployed}.")
|
|
318
|
+
only = _changed_only(repo, session, current, deployed) if opts.changed_only else None
|
|
319
|
+
if not repo.checkout(deployed):
|
|
320
|
+
raise GitError(f"Could not checkout {deployed}.")
|
|
321
|
+
stashed = False
|
|
322
|
+
new = deployed
|
|
323
|
+
try:
|
|
324
|
+
stashed = repo.stash_push_untracked()
|
|
325
|
+
lock = RemoteLock(
|
|
326
|
+
session.primary,
|
|
327
|
+
deployed,
|
|
328
|
+
enabled=opts.lock,
|
|
329
|
+
force=opts.force,
|
|
330
|
+
dry_run=opts.dry_run,
|
|
331
|
+
out=out,
|
|
332
|
+
)
|
|
333
|
+
lock.acquire()
|
|
334
|
+
try:
|
|
335
|
+
download_remote_updates(session, opts, only)
|
|
336
|
+
finally:
|
|
337
|
+
lock.release()
|
|
338
|
+
if not opts.dry_run:
|
|
339
|
+
repo.add_all()
|
|
340
|
+
if repo.has_any_changes():
|
|
341
|
+
body = repo.diff_head_name_status()
|
|
342
|
+
if repo.commit("[git-ftp] remotely untracked modifications", body):
|
|
343
|
+
new = repo.head_sha()
|
|
344
|
+
out.debug(
|
|
345
|
+
f"Uploading commit log to {session.url.display()}{session.deployed_sha1_file}."
|
|
346
|
+
)
|
|
347
|
+
session.primary.put_bytes(f"{new}\n".encode(), session.deployed_sha1_file)
|
|
348
|
+
out.info(f"Last deployment changed from {deployed} to {new}.")
|
|
349
|
+
finally:
|
|
350
|
+
if stashed:
|
|
351
|
+
proc = repo.run("stash", "pop", "-q", ok_codes=())
|
|
352
|
+
if proc.returncode != 0:
|
|
353
|
+
out.warn("Could not restore the stash; run 'git stash pop' yourself.")
|
|
354
|
+
repo.checkout(current)
|
|
355
|
+
if opts.dry_run:
|
|
356
|
+
return
|
|
357
|
+
out.info(f"From {session.url.name()}")
|
|
358
|
+
out.info(f" {deployed}..{new}")
|
|
359
|
+
no_commit = opts.no_commit or session.cfg.get_bool("no-commit")
|
|
360
|
+
if repo.merge(new, no_commit) != 0:
|
|
361
|
+
raise GitError("Merge failed.")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def run_snapshot(cli: CliOptions, url_arg: str | None, directory: str | None, out: Output) -> None:
|
|
365
|
+
session = open_session(cli, url_arg, out, need_repo=False, need_url=False)
|
|
366
|
+
if not (url_arg or os.environ.get("GIT_FTP_URL") or session.cfg.get("url")):
|
|
367
|
+
raise UsageError("Error: give a URL to snapshot.")
|
|
368
|
+
registry.check_available(session.url.scheme)
|
|
369
|
+
raw = url_arg or os.environ.get("GIT_FTP_URL") or session.cfg.get("url")
|
|
370
|
+
target = directory or (raw.rstrip("/").rsplit("/", 1)[-1] if "/" in raw.rstrip("/") else "")
|
|
371
|
+
if not target or "://" in target:
|
|
372
|
+
target = session.url.hostname
|
|
373
|
+
try:
|
|
374
|
+
try:
|
|
375
|
+
data = session.primary.get(session.deployed_sha1_file)
|
|
376
|
+
except RemoteNotFound:
|
|
377
|
+
data = b""
|
|
378
|
+
if data.strip():
|
|
379
|
+
raise UsageError(
|
|
380
|
+
f"Commit found at {session.url.display()}{session.deployed_sha1_file}.\n"
|
|
381
|
+
"The remote directory is managed by another Git repository already.\n"
|
|
382
|
+
"Use 'git ftp pull' inside that repository to download the remote changes,\n"
|
|
383
|
+
"or delete the file on the server to start a new snapshot."
|
|
384
|
+
)
|
|
385
|
+
dest = Path(target).absolute()
|
|
386
|
+
try:
|
|
387
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
388
|
+
except OSError as e:
|
|
389
|
+
raise FilesystemError(f"Error creating directory '{target}'. Aborting.") from e
|
|
390
|
+
if any(dest.iterdir()):
|
|
391
|
+
raise FilesystemError(
|
|
392
|
+
f"Error: The destination directory '{target}' is not empty. Aborting."
|
|
393
|
+
)
|
|
394
|
+
repo = GitRepo.init(dest)
|
|
395
|
+
session.repo = repo
|
|
396
|
+
session.git = repo
|
|
397
|
+
session.syncroot = ""
|
|
398
|
+
download_remote_updates(session, MirrorOptions(), None)
|
|
399
|
+
try:
|
|
400
|
+
repo.add_dot()
|
|
401
|
+
except GitError as e:
|
|
402
|
+
raise GitError("Git: error adding changed files") from e
|
|
403
|
+
if not repo.commit(f"Download {session.url.display()} with git-ftp", allow_empty=True):
|
|
404
|
+
raise GitError("Git: error committing the changes")
|
|
405
|
+
deploy.run(deploy.Action.CATCHUP, session, deploy.DeployOptions())
|
|
406
|
+
finally:
|
|
407
|
+
session.close()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def run_unlock(session: Session) -> None:
|
|
411
|
+
registry.check_available(session.url.scheme)
|
|
412
|
+
session.primary.delete(LOCK_FILE)
|
|
413
|
+
session.out.info("Remote lock removed.")
|
gitftp/options.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Command-line options as a plain dataclass (independent of click)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from gitftp.auth import AuthFlags
|
|
9
|
+
from gitftp.output import Level
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CliOptions:
|
|
14
|
+
user: str | None = None
|
|
15
|
+
password: str | None = None
|
|
16
|
+
ask_password: bool = False
|
|
17
|
+
password_command: str | None = None
|
|
18
|
+
keychain: str | None = None
|
|
19
|
+
key: str | None = None
|
|
20
|
+
pubkey: str | None = None
|
|
21
|
+
key_passphrase: str | None = None
|
|
22
|
+
branch: str | None = None
|
|
23
|
+
commit: str | None = None
|
|
24
|
+
scope: str | None = None
|
|
25
|
+
syncroot: str | None = None
|
|
26
|
+
remote_root: str | None = None
|
|
27
|
+
cacert: str | None = None
|
|
28
|
+
proxy: str | None = None
|
|
29
|
+
jobs: int | None = None
|
|
30
|
+
all: bool = False
|
|
31
|
+
active: bool = False
|
|
32
|
+
lock: bool = False
|
|
33
|
+
dry_run: bool = False
|
|
34
|
+
force: bool = False
|
|
35
|
+
silent: bool = False
|
|
36
|
+
verbose: int = 0
|
|
37
|
+
insecure: bool = False
|
|
38
|
+
disable_epsv: bool = False
|
|
39
|
+
no_commit: bool = False
|
|
40
|
+
changed_only: bool = False
|
|
41
|
+
no_verify: bool = False
|
|
42
|
+
no_post_hooks: bool = False
|
|
43
|
+
enable_post_errors: bool = False
|
|
44
|
+
auto_init: bool = False
|
|
45
|
+
worktree: bool = False
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_kwargs(cls, kw: dict[str, Any]) -> CliOptions:
|
|
49
|
+
fields = {f for f in cls.__dataclass_fields__}
|
|
50
|
+
return cls(**{k: v for k, v in kw.items() if k in fields})
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def level(self) -> Level:
|
|
54
|
+
if self.silent:
|
|
55
|
+
return Level.SILENT
|
|
56
|
+
if self.verbose >= 2:
|
|
57
|
+
return Level.TRACE
|
|
58
|
+
if self.verbose == 1:
|
|
59
|
+
return Level.VERBOSE
|
|
60
|
+
return Level.NORMAL
|
|
61
|
+
|
|
62
|
+
def auth_flags(self) -> AuthFlags:
|
|
63
|
+
return AuthFlags(
|
|
64
|
+
user=self.user,
|
|
65
|
+
password=self.password,
|
|
66
|
+
ask_password=self.ask_password,
|
|
67
|
+
password_command=self.password_command,
|
|
68
|
+
keychain=self.keychain,
|
|
69
|
+
key=self.key,
|
|
70
|
+
pubkey=self.pubkey,
|
|
71
|
+
key_passphrase=self.key_passphrase,
|
|
72
|
+
)
|
gitftp/output.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""All user-facing output.
|
|
2
|
+
|
|
3
|
+
Upstream semantics are kept: progress lines go to stdout at normal verbosity,
|
|
4
|
+
diagnostics (``write_log``) are shown only with ``-v``, and ``-n`` silences
|
|
5
|
+
progress. Fixed: fatal errors always go to stderr, at every verbosity.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import enum
|
|
11
|
+
import getpass
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import TextIO
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Level(enum.IntEnum):
|
|
19
|
+
SILENT = -1
|
|
20
|
+
NORMAL = 0
|
|
21
|
+
VERBOSE = 1
|
|
22
|
+
TRACE = 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Output:
|
|
26
|
+
"""Thread-safe printer with secret redaction."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
level: Level = Level.NORMAL,
|
|
31
|
+
stdout: TextIO | None = None,
|
|
32
|
+
stderr: TextIO | None = None,
|
|
33
|
+
stdin: TextIO | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.level = level
|
|
36
|
+
self._stdout = stdout
|
|
37
|
+
self._stderr = stderr
|
|
38
|
+
self._stdin = stdin
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
self._secrets: list[str] = []
|
|
41
|
+
|
|
42
|
+
# Streams are resolved lazily so click's CliRunner can swap sys.stdout in tests.
|
|
43
|
+
@property
|
|
44
|
+
def stdout(self) -> TextIO:
|
|
45
|
+
return self._stdout or sys.stdout
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def stderr(self) -> TextIO:
|
|
49
|
+
return self._stderr or sys.stderr
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def stdin(self) -> TextIO:
|
|
53
|
+
return self._stdin or sys.stdin
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def tracing(self) -> bool:
|
|
57
|
+
return self.level >= Level.TRACE
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def verbose(self) -> bool:
|
|
61
|
+
return self.level >= Level.VERBOSE
|
|
62
|
+
|
|
63
|
+
# -- secrets -----------------------------------------------------------
|
|
64
|
+
def add_secret(self, secret: str | None) -> None:
|
|
65
|
+
if secret and secret not in self._secrets:
|
|
66
|
+
self._secrets.append(secret)
|
|
67
|
+
|
|
68
|
+
def redact(self, text: str) -> str:
|
|
69
|
+
for s in self._secrets:
|
|
70
|
+
text = text.replace(s, "***")
|
|
71
|
+
return text
|
|
72
|
+
|
|
73
|
+
# -- writing -----------------------------------------------------------
|
|
74
|
+
def _write(self, stream: TextIO, text: str) -> None:
|
|
75
|
+
with self._lock:
|
|
76
|
+
stream.write(text + "\n")
|
|
77
|
+
stream.flush()
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _stamp() -> str:
|
|
81
|
+
return time.strftime("%a %b %e %H:%M:%S %Z %Y")
|
|
82
|
+
|
|
83
|
+
def info(self, msg: str) -> None:
|
|
84
|
+
"""A progress line. Plain at NORMAL, timestamped at VERBOSE, hidden at SILENT."""
|
|
85
|
+
if self.level == Level.NORMAL:
|
|
86
|
+
self._write(self.stdout, msg)
|
|
87
|
+
elif self.level >= Level.VERBOSE:
|
|
88
|
+
self._write(self.stdout, f"{self._stamp()}: {msg}")
|
|
89
|
+
|
|
90
|
+
def debug(self, msg: str) -> None:
|
|
91
|
+
"""Upstream ``write_log``: shown only with ``-v``."""
|
|
92
|
+
if self.level >= Level.VERBOSE:
|
|
93
|
+
self._write(self.stderr, f"{self._stamp()}: {self.redact(msg)}")
|
|
94
|
+
|
|
95
|
+
def warn(self, msg: str) -> None:
|
|
96
|
+
if self.level > Level.SILENT:
|
|
97
|
+
self._write(self.stderr, f"WARNING: {self.redact(msg)}")
|
|
98
|
+
|
|
99
|
+
def trace(self, msg: str) -> None:
|
|
100
|
+
"""Protocol-level chatter, shown with ``-vv``."""
|
|
101
|
+
if self.level >= Level.TRACE:
|
|
102
|
+
self._write(self.stderr, self.redact(msg))
|
|
103
|
+
|
|
104
|
+
def fatal(self, msg: str) -> None:
|
|
105
|
+
"""Always printed, always to stderr."""
|
|
106
|
+
self._write(self.stderr, f"fatal: {self.redact(msg)}")
|
|
107
|
+
|
|
108
|
+
def raw(self, text: str) -> None:
|
|
109
|
+
"""Verbatim text to stdout regardless of level (help, version)."""
|
|
110
|
+
with self._lock:
|
|
111
|
+
self.stdout.write(text)
|
|
112
|
+
if not text.endswith("\n"):
|
|
113
|
+
self.stdout.write("\n")
|
|
114
|
+
self.stdout.flush()
|
|
115
|
+
|
|
116
|
+
# -- reading -----------------------------------------------------------
|
|
117
|
+
def ask(self, prompt: str) -> str:
|
|
118
|
+
"""Print ``prompt`` (no newline) and read one line; EOF reads as ''."""
|
|
119
|
+
with self._lock:
|
|
120
|
+
self.stdout.write(prompt)
|
|
121
|
+
self.stdout.flush()
|
|
122
|
+
line = self.stdin.readline()
|
|
123
|
+
return line.rstrip("\r\n")
|
|
124
|
+
|
|
125
|
+
def prompt_secret(self, prompt: str) -> str:
|
|
126
|
+
if self._stdin is not None or not self.stdin.isatty():
|
|
127
|
+
# Non-interactive: read a line without echo games (tests, pipes).
|
|
128
|
+
with self._lock:
|
|
129
|
+
self.stderr.write(prompt)
|
|
130
|
+
self.stderr.flush()
|
|
131
|
+
return self.stdin.readline().rstrip("\r\n")
|
|
132
|
+
return getpass.getpass(prompt, stream=self.stderr)
|