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/deploy.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""The init / push / catchup engine, step for step as upstream's ``action_push``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from gitftp import changeset as csmod
|
|
10
|
+
from gitftp.errors import (
|
|
11
|
+
Aborted,
|
|
12
|
+
DownloadError,
|
|
13
|
+
GitError,
|
|
14
|
+
GitFtpError,
|
|
15
|
+
HookError,
|
|
16
|
+
UploadError,
|
|
17
|
+
UsageError,
|
|
18
|
+
)
|
|
19
|
+
from gitftp.gitrepo import UnknownCommit
|
|
20
|
+
from gitftp.hooks import POST_PUSH, PRE_PUSH, run_hook
|
|
21
|
+
from gitftp.lock import RemoteLock
|
|
22
|
+
from gitftp.options import CliOptions
|
|
23
|
+
from gitftp.session import Session
|
|
24
|
+
from gitftp.transfer import DeleteTask, TransferError, TransferPool, UploadTask
|
|
25
|
+
from gitftp.transport import registry
|
|
26
|
+
from gitftp.transport.base import RemoteNotFound
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Action(enum.Enum):
|
|
30
|
+
INIT = "init"
|
|
31
|
+
PUSH = "push"
|
|
32
|
+
CATCHUP = "catchup"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class DeployOptions:
|
|
37
|
+
force: bool = False
|
|
38
|
+
dry_run: bool = False
|
|
39
|
+
all: bool = False
|
|
40
|
+
auto_init: bool = False
|
|
41
|
+
commit: str | None = None
|
|
42
|
+
branch: str | None = None
|
|
43
|
+
lock: bool = False
|
|
44
|
+
no_verify: bool = False
|
|
45
|
+
no_post_hooks: bool = False
|
|
46
|
+
enable_post_errors: bool = False
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_cli(cls, opts: CliOptions) -> DeployOptions:
|
|
50
|
+
return cls(
|
|
51
|
+
force=opts.force,
|
|
52
|
+
dry_run=opts.dry_run,
|
|
53
|
+
all=opts.all,
|
|
54
|
+
auto_init=opts.auto_init,
|
|
55
|
+
commit=opts.commit,
|
|
56
|
+
branch=opts.branch,
|
|
57
|
+
lock=opts.lock,
|
|
58
|
+
no_verify=opts.no_verify,
|
|
59
|
+
no_post_hooks=opts.no_post_hooks,
|
|
60
|
+
enable_post_errors=opts.enable_post_errors,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def for_submodule(self) -> DeployOptions:
|
|
64
|
+
"""What upstream forwards to the recursive invocation (always ``--force``)."""
|
|
65
|
+
return DeployOptions(force=True, dry_run=self.dry_run, all=self.all)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class DeployResult:
|
|
70
|
+
local_sha: str
|
|
71
|
+
deployed_sha: str | None
|
|
72
|
+
uploaded: list[str] = field(default_factory=list)
|
|
73
|
+
deleted: list[str] = field(default_factory=list)
|
|
74
|
+
up_to_date: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run(action: Action, session: Session, opts: DeployOptions) -> DeployResult:
|
|
78
|
+
return _Run(action, session, opts).run()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _Run:
|
|
82
|
+
def __init__(self, action: Action, session: Session, opts: DeployOptions) -> None:
|
|
83
|
+
self.action = action
|
|
84
|
+
self.session = session
|
|
85
|
+
self.opts = opts
|
|
86
|
+
self.out = session.out
|
|
87
|
+
self.repo = session.require_repo()
|
|
88
|
+
self.url = session.url
|
|
89
|
+
self._source_root: Path = self.repo.root
|
|
90
|
+
|
|
91
|
+
# -- entry -------------------------------------------------------------
|
|
92
|
+
def run(self) -> DeployResult:
|
|
93
|
+
self.session.git.check_version()
|
|
94
|
+
if self.repo.is_dirty():
|
|
95
|
+
raise GitError("Dirty repository: Having uncommitted changes. Exiting...")
|
|
96
|
+
original_branch: str | None = None
|
|
97
|
+
branch = self.opts.branch or self.session.cfg.get("branch")
|
|
98
|
+
if branch:
|
|
99
|
+
original_branch = self.repo.current_branch()
|
|
100
|
+
if not self.repo.checkout(branch):
|
|
101
|
+
raise GitError(f"'{branch}' is not a valid branch! Exiting...")
|
|
102
|
+
try:
|
|
103
|
+
registry.check_available(self.url.scheme)
|
|
104
|
+
if self.action is Action.CATCHUP:
|
|
105
|
+
return self._catchup()
|
|
106
|
+
return self._deploy()
|
|
107
|
+
finally:
|
|
108
|
+
if original_branch:
|
|
109
|
+
self.repo.checkout(original_branch)
|
|
110
|
+
|
|
111
|
+
# -- catchup -----------------------------------------------------------
|
|
112
|
+
def _catchup(self) -> DeployResult:
|
|
113
|
+
local = self.repo.head_sha()
|
|
114
|
+
self._upload_log(local, None)
|
|
115
|
+
for sub, initialised in self.repo.submodules(self.session.syncroot).items():
|
|
116
|
+
if not initialised:
|
|
117
|
+
continue
|
|
118
|
+
self.out.info(f"Catching up submodule {sub}.")
|
|
119
|
+
subsession = self.session.for_submodule(sub)
|
|
120
|
+
try:
|
|
121
|
+
run(Action.CATCHUP, subsession, self.opts.for_submodule())
|
|
122
|
+
finally:
|
|
123
|
+
subsession.close()
|
|
124
|
+
return DeployResult(local_sha=local, deployed_sha=None)
|
|
125
|
+
|
|
126
|
+
# -- init / push -------------------------------------------------------
|
|
127
|
+
def _deploy(self) -> DeployResult:
|
|
128
|
+
deployed, take_all = self._deployed_sha()
|
|
129
|
+
local = self.repo.head_sha()
|
|
130
|
+
cs = self._changeset(deployed, take_all, local)
|
|
131
|
+
if cs is None:
|
|
132
|
+
self.out.info(f"No changed files for {self.url.name()}. Everything up-to-date.")
|
|
133
|
+
return DeployResult(local_sha=local, deployed_sha=deployed, up_to_date=True)
|
|
134
|
+
|
|
135
|
+
scope_or_host = self.session.scope or self.url.host
|
|
136
|
+
if not self.opts.no_verify:
|
|
137
|
+
rc = run_hook(
|
|
138
|
+
self.repo,
|
|
139
|
+
PRE_PUSH,
|
|
140
|
+
[scope_or_host, self.url.display(), local, deployed or ""],
|
|
141
|
+
cs.hook_status(),
|
|
142
|
+
self.out,
|
|
143
|
+
)
|
|
144
|
+
if rc:
|
|
145
|
+
raise HookError(f"{PRE_PUSH} hook failed with exit code {rc}.")
|
|
146
|
+
|
|
147
|
+
lock = RemoteLock(
|
|
148
|
+
self.session.primary,
|
|
149
|
+
local,
|
|
150
|
+
enabled=self.opts.lock,
|
|
151
|
+
force=self.opts.force,
|
|
152
|
+
dry_run=self.opts.dry_run,
|
|
153
|
+
out=self.out,
|
|
154
|
+
)
|
|
155
|
+
lock.acquire()
|
|
156
|
+
try:
|
|
157
|
+
self._run_sync(cs, local)
|
|
158
|
+
self._upload_log(local, deployed)
|
|
159
|
+
finally:
|
|
160
|
+
lock.release()
|
|
161
|
+
|
|
162
|
+
if not self.opts.no_post_hooks:
|
|
163
|
+
rc = run_hook(
|
|
164
|
+
self.repo,
|
|
165
|
+
POST_PUSH,
|
|
166
|
+
[scope_or_host, self.url.display(), local, deployed or ""],
|
|
167
|
+
b"",
|
|
168
|
+
self.out,
|
|
169
|
+
)
|
|
170
|
+
if rc:
|
|
171
|
+
if self.opts.enable_post_errors:
|
|
172
|
+
raise HookError(f"{POST_PUSH} hook failed with exit code {rc}.")
|
|
173
|
+
self.out.debug(f"{POST_PUSH} hook failed with exit code {rc}, ignoring.")
|
|
174
|
+
return DeployResult(
|
|
175
|
+
local_sha=local, deployed_sha=deployed, uploaded=cs.uploads, deleted=cs.deletes
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _deployed_sha(self) -> tuple[str | None, bool]:
|
|
179
|
+
s = self.session
|
|
180
|
+
log_file = s.deployed_sha1_file
|
|
181
|
+
if self.action is Action.INIT:
|
|
182
|
+
self.out.debug(f"Checking remote access to {self.url.display()}.")
|
|
183
|
+
try:
|
|
184
|
+
s.primary.mkdir_p("")
|
|
185
|
+
data = s.primary.get(log_file)
|
|
186
|
+
except RemoteNotFound:
|
|
187
|
+
data = b""
|
|
188
|
+
except DownloadError as e:
|
|
189
|
+
raise UploadError(str(e)) from e
|
|
190
|
+
if data.strip():
|
|
191
|
+
raise UsageError("Commit found, use 'git ftp push' to sync. Exiting...")
|
|
192
|
+
return None, True
|
|
193
|
+
|
|
194
|
+
if self.opts.commit:
|
|
195
|
+
self.out.debug(f"Using commit {self.opts.commit} as the last deployed commit.")
|
|
196
|
+
return self.opts.commit, self.opts.all
|
|
197
|
+
|
|
198
|
+
self.out.debug(f"Retrieving last commit from {self.url.display()}.")
|
|
199
|
+
try:
|
|
200
|
+
data = s.primary.get(log_file)
|
|
201
|
+
except RemoteNotFound:
|
|
202
|
+
data = b""
|
|
203
|
+
except DownloadError as e:
|
|
204
|
+
raise DownloadError(f"Could not get last commit from {self.url.display()}. {e}") from e
|
|
205
|
+
deployed = data.decode("utf-8", "replace").strip().split("\n")[0].strip()
|
|
206
|
+
if not deployed:
|
|
207
|
+
if self.opts.auto_init:
|
|
208
|
+
s.primary.mkdir_p("")
|
|
209
|
+
self.out.debug(
|
|
210
|
+
f"Uploading all files since no commit was found at {self.url.display()}."
|
|
211
|
+
)
|
|
212
|
+
return None, True
|
|
213
|
+
raise DownloadError(
|
|
214
|
+
"Could not get last commit. Use 'git ftp init' for the initial push."
|
|
215
|
+
)
|
|
216
|
+
self.out.debug(f"Last deployed SHA1 for {self.url.name()} is {deployed}.")
|
|
217
|
+
return deployed, self.opts.all
|
|
218
|
+
|
|
219
|
+
def _changeset(
|
|
220
|
+
self, deployed: str | None, take_all: bool, local: str
|
|
221
|
+
) -> csmod.ChangeSet | None:
|
|
222
|
+
repo, syncroot, out = self.repo, self.session.syncroot, self.out
|
|
223
|
+
if not take_all and deployed == local:
|
|
224
|
+
return None
|
|
225
|
+
try:
|
|
226
|
+
return csmod.build(repo, syncroot, deployed, take_all, out)
|
|
227
|
+
except UnknownCommit:
|
|
228
|
+
pass
|
|
229
|
+
if self.opts.force:
|
|
230
|
+
out.info("Unknown SHA1 object, could not determine changed files, taking all files.")
|
|
231
|
+
return csmod.build(repo, syncroot, deployed, True, out)
|
|
232
|
+
out.info(
|
|
233
|
+
"Unknown SHA1 object, make sure you are deploying the right branch "
|
|
234
|
+
"and it is up-to-date."
|
|
235
|
+
)
|
|
236
|
+
answer = out.ask("Do you want to ignore and upload all files again? [y/N]: ")
|
|
237
|
+
if answer == "":
|
|
238
|
+
out.info("Aborting...")
|
|
239
|
+
raise UsageError("")
|
|
240
|
+
if answer.lower() != "y":
|
|
241
|
+
out.info("Aborting...")
|
|
242
|
+
raise Aborted()
|
|
243
|
+
return csmod.build(repo, syncroot, deployed, True, out)
|
|
244
|
+
|
|
245
|
+
def _run_sync(self, cs: csmod.ChangeSet, local: str) -> None:
|
|
246
|
+
"""Read the upload from a temporary worktree when ``--worktree`` is set."""
|
|
247
|
+
if self.session.worktree and not self.opts.dry_run and cs.uploads:
|
|
248
|
+
self.out.debug("Creating a temporary worktree for a consistent upload.")
|
|
249
|
+
with self.repo.temporary_worktree(local) as tree:
|
|
250
|
+
self._source_root = tree
|
|
251
|
+
try:
|
|
252
|
+
self._sync(cs)
|
|
253
|
+
finally:
|
|
254
|
+
self._source_root = self.repo.root
|
|
255
|
+
else:
|
|
256
|
+
self._sync(cs)
|
|
257
|
+
|
|
258
|
+
def _source(self, path: str) -> Path:
|
|
259
|
+
"""Where to read the bytes of ``path`` from.
|
|
260
|
+
|
|
261
|
+
Tracked files come from the worktree (``_source_root``); an untracked file
|
|
262
|
+
added by ``.git-ftp-include`` is not in the commit, so it falls back to the
|
|
263
|
+
live working tree.
|
|
264
|
+
"""
|
|
265
|
+
candidate = self._source_root / path
|
|
266
|
+
return candidate if candidate.exists() else self.repo.root / path
|
|
267
|
+
|
|
268
|
+
def _sync(self, cs: csmod.ChangeSet) -> None:
|
|
269
|
+
out, s = self.out, self.session
|
|
270
|
+
total = cs.total()
|
|
271
|
+
if total == 0:
|
|
272
|
+
out.info("There are no files to sync.")
|
|
273
|
+
return
|
|
274
|
+
out.info(f"{total} file{'s' if total != 1 else ''} to sync:")
|
|
275
|
+
done = 0
|
|
276
|
+
uploads: list[UploadTask] = []
|
|
277
|
+
for path in cs.uploads:
|
|
278
|
+
done += 1
|
|
279
|
+
out.info(f"[{done} of {total}] Buffered for upload '{path}'.")
|
|
280
|
+
if path in cs.submodules:
|
|
281
|
+
self._sync_submodule(path)
|
|
282
|
+
continue
|
|
283
|
+
local = self._source(path)
|
|
284
|
+
if local.is_dir():
|
|
285
|
+
out.debug(f"Skipping directory '{path}'.")
|
|
286
|
+
continue
|
|
287
|
+
uploads.append(
|
|
288
|
+
UploadTask(
|
|
289
|
+
local=local,
|
|
290
|
+
remote=csmod.remote_path(path, s.syncroot),
|
|
291
|
+
size=local.stat().st_size,
|
|
292
|
+
label=path,
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
deletes: list[DeleteTask] = []
|
|
296
|
+
with TransferPool(s.connect, s.jobs, out, primary=s.primary) as pool:
|
|
297
|
+
if uploads and not self.opts.dry_run:
|
|
298
|
+
out.info("Uploading ...")
|
|
299
|
+
try:
|
|
300
|
+
pool.upload(uploads)
|
|
301
|
+
except TransferError as e:
|
|
302
|
+
raise UploadError(f"Could not upload files. {e}") from e
|
|
303
|
+
for path in cs.deletes:
|
|
304
|
+
done += 1
|
|
305
|
+
out.info(f"[{done} of {total}] Buffered for delete '{path}'.")
|
|
306
|
+
deletes.append(DeleteTask(remote=csmod.remote_path(path, s.syncroot), label=path))
|
|
307
|
+
if deletes and not self.opts.dry_run:
|
|
308
|
+
out.info("Deleting ...")
|
|
309
|
+
errors = pool.delete(deletes)
|
|
310
|
+
for err in errors:
|
|
311
|
+
out.debug(f"Could not delete {err.label}, continuing... ({err.cause})")
|
|
312
|
+
if errors:
|
|
313
|
+
out.warn("Some files and/or directories could not be deleted.")
|
|
314
|
+
|
|
315
|
+
def _sync_submodule(self, path: str) -> None:
|
|
316
|
+
display = csmod.remote_path(path, self.session.syncroot)
|
|
317
|
+
self.out.info(f"Handling submodule sync for {display}.")
|
|
318
|
+
subsession = self.session.for_submodule(path)
|
|
319
|
+
sub_opts = self.opts.for_submodule()
|
|
320
|
+
try:
|
|
321
|
+
try:
|
|
322
|
+
run(self.action, subsession, sub_opts)
|
|
323
|
+
except DownloadError:
|
|
324
|
+
if self.action is not Action.PUSH:
|
|
325
|
+
raise
|
|
326
|
+
self.out.info(f"Could not push {display}, trying to init...")
|
|
327
|
+
run(Action.INIT, subsession, sub_opts)
|
|
328
|
+
except GitFtpError as e:
|
|
329
|
+
raise UploadError(f"Failed to sync submodules. ({e})") from e
|
|
330
|
+
finally:
|
|
331
|
+
subsession.close()
|
|
332
|
+
|
|
333
|
+
def _upload_log(self, local: str, deployed: str | None) -> None:
|
|
334
|
+
s, out = self.session, self.out
|
|
335
|
+
out.debug(f"Uploading commit log to {self.url.display()}{s.deployed_sha1_file}.")
|
|
336
|
+
if not self.opts.dry_run:
|
|
337
|
+
try:
|
|
338
|
+
s.primary.put_bytes(f"{local}\n".encode(), s.deployed_sha1_file)
|
|
339
|
+
except (UploadError, DownloadError) as e:
|
|
340
|
+
raise UploadError(f"Could not upload. {e}") from e
|
|
341
|
+
out.info(f"Last deployment changed from {deployed or ''} to {local}.")
|
gitftp/errors.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Exit codes and the exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Every layer raises a :class:`GitFtpError` subclass; only :func:`gitftp.cli.main`
|
|
4
|
+
maps them to process exit codes. The numeric codes are upstream git-ftp's.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExitCode(enum.IntEnum):
|
|
13
|
+
"""Process exit codes, identical to upstream git-ftp 1.6.0."""
|
|
14
|
+
|
|
15
|
+
OK = 0
|
|
16
|
+
UNKNOWN = 1
|
|
17
|
+
USAGE = 2
|
|
18
|
+
MISSING_ARGUMENTS = 3
|
|
19
|
+
UPLOAD = 4
|
|
20
|
+
DOWNLOAD = 5
|
|
21
|
+
UNKNOWN_PROTOCOL = 6
|
|
22
|
+
REMOTE_LOCKED = 7
|
|
23
|
+
GIT = 8
|
|
24
|
+
HOOK = 9
|
|
25
|
+
FILESYSTEM = 10
|
|
26
|
+
INTERRUPTED = 130
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GitFtpError(Exception):
|
|
30
|
+
"""Base class: a fatal condition with an exit code."""
|
|
31
|
+
|
|
32
|
+
code: ExitCode = ExitCode.UNKNOWN
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, *, code: ExitCode | None = None) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.message = message
|
|
37
|
+
if code is not None:
|
|
38
|
+
self.code = code
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return self.message
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UsageError(GitFtpError):
|
|
45
|
+
code = ExitCode.USAGE
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MissingArgumentError(GitFtpError):
|
|
49
|
+
code = ExitCode.MISSING_ARGUMENTS
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UploadError(GitFtpError):
|
|
53
|
+
code = ExitCode.UPLOAD
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DownloadError(GitFtpError):
|
|
57
|
+
code = ExitCode.DOWNLOAD
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class UnknownProtocolError(GitFtpError):
|
|
61
|
+
code = ExitCode.UNKNOWN_PROTOCOL
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RemoteLockedError(GitFtpError):
|
|
65
|
+
code = ExitCode.REMOTE_LOCKED
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class GitError(GitFtpError):
|
|
69
|
+
code = ExitCode.GIT
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class HookError(GitFtpError):
|
|
73
|
+
code = ExitCode.HOOK
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FilesystemError(GitFtpError):
|
|
77
|
+
code = ExitCode.FILESYSTEM
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Aborted(GitFtpError):
|
|
81
|
+
"""The user declined at a prompt. Not an error: exit 0, as upstream."""
|
|
82
|
+
|
|
83
|
+
code = ExitCode.OK
|
|
84
|
+
|
|
85
|
+
def __init__(self, message: str = "Aborting...") -> None:
|
|
86
|
+
super().__init__(message)
|