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/__main__.py
ADDED
gitftp/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '2.0.0.dev0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 0, 0, 'dev0')
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
gitftp/auth.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Credential resolution.
|
|
2
|
+
|
|
3
|
+
Precedence (first hit wins):
|
|
4
|
+
|
|
5
|
+
user: -u flag (bare -u => the local user) > URL userinfo > $GIT_FTP_USER > git-ftp.user
|
|
6
|
+
password: -p > -P prompt > URL userinfo > password-command > keychain > $GIT_FTP_PASSWORD
|
|
7
|
+
> git-ftp.password (present-but-empty counts) > none
|
|
8
|
+
netrc: consulted only when no user and no password were found at all.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import getpass
|
|
14
|
+
import netrc
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from gitftp.config import Config
|
|
24
|
+
from gitftp.errors import MissingArgumentError
|
|
25
|
+
from gitftp.output import Output
|
|
26
|
+
from gitftp.url import RemoteURL
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AuthFlags:
|
|
31
|
+
user: str | None = None # None = not given; "" = bare -u
|
|
32
|
+
password: str | None = None
|
|
33
|
+
ask_password: bool = False
|
|
34
|
+
password_command: str | None = None
|
|
35
|
+
keychain: str | None = None # None = not given; "" = bare -k
|
|
36
|
+
key: str | None = None
|
|
37
|
+
pubkey: str | None = None
|
|
38
|
+
key_passphrase: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Credentials:
|
|
43
|
+
user: str = ""
|
|
44
|
+
password: str | None = None
|
|
45
|
+
key: str | None = None
|
|
46
|
+
pubkey: str | None = None
|
|
47
|
+
key_passphrase: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def local_user(env: Mapping[str, str]) -> str:
|
|
51
|
+
for var in ("USER", "USERNAME", "LOGNAME"):
|
|
52
|
+
if env.get(var):
|
|
53
|
+
return env[var]
|
|
54
|
+
try:
|
|
55
|
+
return getpass.getuser()
|
|
56
|
+
except Exception:
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_password_command(command: str, out: Output) -> str:
|
|
61
|
+
"""Run ``command`` through the shell; the first line of stdout is the password."""
|
|
62
|
+
out.debug("Running password command.")
|
|
63
|
+
if sys.platform == "win32":
|
|
64
|
+
argv: list[str] = ["cmd.exe", "/c", command]
|
|
65
|
+
else:
|
|
66
|
+
argv = ["/bin/sh", "-c", command]
|
|
67
|
+
try:
|
|
68
|
+
proc = subprocess.run(argv, stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, check=False)
|
|
69
|
+
except OSError as e:
|
|
70
|
+
raise MissingArgumentError(f"Password command failed: {e}") from e
|
|
71
|
+
if proc.returncode != 0:
|
|
72
|
+
raise MissingArgumentError(f"Password command failed with exit code {proc.returncode}.")
|
|
73
|
+
text = proc.stdout.decode("utf-8", "surrogateescape")
|
|
74
|
+
return text.split("\n", 1)[0].rstrip("\r")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def keychain_lookup(account: str, host: str, out: Output) -> str | None:
|
|
78
|
+
"""macOS ``security find-internet-password``; None when not found."""
|
|
79
|
+
argv = ["security", "find-internet-password", "-g", "-a", account]
|
|
80
|
+
if host:
|
|
81
|
+
argv += ["-s", host]
|
|
82
|
+
try:
|
|
83
|
+
proc = subprocess.run(argv, capture_output=True, check=False)
|
|
84
|
+
except OSError:
|
|
85
|
+
return None
|
|
86
|
+
err = proc.stderr.decode("utf-8", "replace")
|
|
87
|
+
if proc.returncode != 0 or "could not be found" in err:
|
|
88
|
+
return None
|
|
89
|
+
m = re.search(r'^password: (?:0x([0-9A-Fa-f]+)\s+)?"(.*)"$', err, re.MULTILINE)
|
|
90
|
+
if not m:
|
|
91
|
+
return None
|
|
92
|
+
if m.group(1):
|
|
93
|
+
return bytes.fromhex(m.group(1)).decode("utf-8", "replace")
|
|
94
|
+
return m.group(2)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def netrc_lookup(hostname: str, env: Mapping[str, str]) -> tuple[str, str | None] | None:
|
|
98
|
+
if env.get("NETRC"):
|
|
99
|
+
candidates = [Path(env["NETRC"])]
|
|
100
|
+
else:
|
|
101
|
+
home = Path(env.get("HOME") or Path.home())
|
|
102
|
+
candidates = [home / ".netrc", home / "_netrc"]
|
|
103
|
+
for path in candidates:
|
|
104
|
+
if not path.is_file():
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
rc = netrc.netrc(str(path))
|
|
108
|
+
except (netrc.NetrcParseError, OSError):
|
|
109
|
+
continue
|
|
110
|
+
entry = rc.authenticators(hostname)
|
|
111
|
+
if entry is None:
|
|
112
|
+
for name, auth in rc.hosts.items():
|
|
113
|
+
if name.lower() == hostname.lower():
|
|
114
|
+
entry = auth
|
|
115
|
+
break
|
|
116
|
+
if entry is None and "default" in rc.hosts:
|
|
117
|
+
entry = rc.hosts["default"]
|
|
118
|
+
if entry is not None:
|
|
119
|
+
login, _account, password = entry
|
|
120
|
+
return login or "", password or None
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def expand_path(p: str | None) -> str | None:
|
|
125
|
+
if not p:
|
|
126
|
+
return None
|
|
127
|
+
return os.path.expanduser(p)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve(
|
|
131
|
+
cfg: Config,
|
|
132
|
+
url: RemoteURL,
|
|
133
|
+
flags: AuthFlags,
|
|
134
|
+
out: Output,
|
|
135
|
+
*,
|
|
136
|
+
env: Mapping[str, str] | None = None,
|
|
137
|
+
) -> Credentials:
|
|
138
|
+
env = os.environ if env is None else env
|
|
139
|
+
|
|
140
|
+
# -- user ----------------------------------------------------------------
|
|
141
|
+
if flags.user is not None:
|
|
142
|
+
user = flags.user or local_user(env)
|
|
143
|
+
elif url.user is not None:
|
|
144
|
+
user = url.user
|
|
145
|
+
elif env.get("GIT_FTP_USER"):
|
|
146
|
+
user = env["GIT_FTP_USER"]
|
|
147
|
+
else:
|
|
148
|
+
user = cfg.get("user", "")
|
|
149
|
+
|
|
150
|
+
# -- password ------------------------------------------------------------
|
|
151
|
+
password: str | None
|
|
152
|
+
if flags.password is not None:
|
|
153
|
+
password = flags.password
|
|
154
|
+
elif flags.ask_password:
|
|
155
|
+
password = out.prompt_secret("Password: ")
|
|
156
|
+
elif url.password is not None:
|
|
157
|
+
password = url.password
|
|
158
|
+
else:
|
|
159
|
+
password = None
|
|
160
|
+
command = flags.password_command or cfg.get("password-command")
|
|
161
|
+
keychain_spec = flags.keychain if flags.keychain is not None else cfg.lookup("keychain")
|
|
162
|
+
if command:
|
|
163
|
+
password = run_password_command(command, out)
|
|
164
|
+
elif keychain_spec is not None:
|
|
165
|
+
if sys.platform != "darwin":
|
|
166
|
+
out.debug("Ignoring -k on non-Darwin systems.")
|
|
167
|
+
else:
|
|
168
|
+
account, host = user, url.hostname
|
|
169
|
+
if "@" in keychain_spec:
|
|
170
|
+
a, _, h = keychain_spec.partition("@")
|
|
171
|
+
account = a or account
|
|
172
|
+
host = h or host
|
|
173
|
+
elif keychain_spec:
|
|
174
|
+
account = keychain_spec
|
|
175
|
+
if not account:
|
|
176
|
+
raise MissingArgumentError("Missing keychain account.")
|
|
177
|
+
found = keychain_lookup(account, host, out)
|
|
178
|
+
if found is None:
|
|
179
|
+
raise MissingArgumentError(
|
|
180
|
+
f"Password not found in keychain for account '{account} @ {host}'."
|
|
181
|
+
)
|
|
182
|
+
password = found
|
|
183
|
+
if password is None:
|
|
184
|
+
if env.get("GIT_FTP_PASSWORD"):
|
|
185
|
+
password = env["GIT_FTP_PASSWORD"]
|
|
186
|
+
else:
|
|
187
|
+
password = cfg.lookup("password")
|
|
188
|
+
|
|
189
|
+
# -- netrc ---------------------------------------------------------------
|
|
190
|
+
if user == "" and password is None:
|
|
191
|
+
entry = netrc_lookup(url.hostname, env)
|
|
192
|
+
if entry is not None:
|
|
193
|
+
out.debug("Using credentials from netrc.")
|
|
194
|
+
user, password = entry
|
|
195
|
+
|
|
196
|
+
# -- ssh keys ------------------------------------------------------------
|
|
197
|
+
key = expand_path(flags.key or cfg.get("key"))
|
|
198
|
+
pubkey = expand_path(flags.pubkey or cfg.get("pubkey"))
|
|
199
|
+
if key and not pubkey and os.access(f"{key}.pub", os.R_OK):
|
|
200
|
+
pubkey = f"{key}.pub"
|
|
201
|
+
passphrase = (
|
|
202
|
+
flags.key_passphrase if flags.key_passphrase is not None else cfg.lookup("key-passphrase")
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
out.add_secret(password)
|
|
206
|
+
out.add_secret(passphrase)
|
|
207
|
+
return Credentials(
|
|
208
|
+
user=user, password=password, key=key, pubkey=pubkey, key_passphrase=passphrase
|
|
209
|
+
)
|
gitftp/changeset.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Compute what to upload and delete, in upstream's order.
|
|
2
|
+
|
|
3
|
+
1. all tracked files (init, --all) or ``git diff`` against the deployed commit
|
|
4
|
+
2. ``.git-ftp-include`` rules (may add uploads and deletes)
|
|
5
|
+
3. ``.git-ftp-ignore`` patterns (remove from both lists)
|
|
6
|
+
4. sort, deduplicate
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from gitftp import ignore as ignoremod
|
|
14
|
+
from gitftp import include as includemod
|
|
15
|
+
from gitftp.gitrepo import GitRepo, UnknownCommit
|
|
16
|
+
from gitftp.output import Output
|
|
17
|
+
|
|
18
|
+
__all__ = ["ChangeSet", "UnknownCommit", "build"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ChangeSet:
|
|
23
|
+
uploads: list[str] = field(default_factory=list)
|
|
24
|
+
deletes: list[str] = field(default_factory=list)
|
|
25
|
+
submodules: set[str] = field(default_factory=set)
|
|
26
|
+
|
|
27
|
+
def is_empty(self) -> bool:
|
|
28
|
+
return not self.uploads and not self.deletes
|
|
29
|
+
|
|
30
|
+
def total(self) -> int:
|
|
31
|
+
return len(self.uploads) + len(self.deletes)
|
|
32
|
+
|
|
33
|
+
def hook_status(self) -> bytes:
|
|
34
|
+
"""NUL-separated ``A <path>`` / ``D <path>`` lines for the pre-push hook."""
|
|
35
|
+
parts = [f"A {p}".encode("utf-8", "surrogateescape") for p in self.uploads]
|
|
36
|
+
parts += [f"D {p}".encode("utf-8", "surrogateescape") for p in self.deletes]
|
|
37
|
+
return b"".join(p + b"\0" for p in parts)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def remote_path(git_path: str, syncroot: str) -> str:
|
|
41
|
+
"""Strip the syncroot prefix (a plain prefix, not a glob as upstream did)."""
|
|
42
|
+
if syncroot and git_path.startswith(syncroot):
|
|
43
|
+
return git_path[len(syncroot) :]
|
|
44
|
+
return git_path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sorted_unique(items: list[str]) -> list[str]:
|
|
48
|
+
return sorted(set(items), key=lambda s: s.encode("utf-8", "surrogateescape"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build(
|
|
52
|
+
repo: GitRepo,
|
|
53
|
+
syncroot: str,
|
|
54
|
+
deployed_sha: str | None,
|
|
55
|
+
take_all: bool,
|
|
56
|
+
out: Output,
|
|
57
|
+
) -> ChangeSet:
|
|
58
|
+
"""Raises :class:`UnknownCommit` when ``deployed_sha`` is unknown to git."""
|
|
59
|
+
if take_all or not deployed_sha:
|
|
60
|
+
uploads = repo.ls_files(syncroot)
|
|
61
|
+
deletes: list[str] = []
|
|
62
|
+
against = repo.empty_tree()
|
|
63
|
+
else:
|
|
64
|
+
uploads = repo.diff_names(deployed_sha, "AMT", syncroot)
|
|
65
|
+
deletes = repo.diff_names(deployed_sha, "D", syncroot)
|
|
66
|
+
against = deployed_sha
|
|
67
|
+
|
|
68
|
+
rules = includemod.load_rules(repo.root)
|
|
69
|
+
if rules:
|
|
70
|
+
inc_up, inc_del = includemod.expand(rules, repo, syncroot, against, out)
|
|
71
|
+
uploads += inc_up
|
|
72
|
+
deletes += inc_del
|
|
73
|
+
|
|
74
|
+
ignore = ignoremod.IgnoreRules.load(repo.root)
|
|
75
|
+
if len(ignore):
|
|
76
|
+
uploads = ignore.filter(uploads)
|
|
77
|
+
deletes = ignore.filter(deletes)
|
|
78
|
+
|
|
79
|
+
subs = repo.submodules(syncroot)
|
|
80
|
+
# Uninitialised submodules are gitlinks without a working tree: nothing to upload.
|
|
81
|
+
uninitialised = {p for p, ok in subs.items() if not ok}
|
|
82
|
+
uploads = [p for p in uploads if p not in uninitialised]
|
|
83
|
+
uploads = _sorted_unique(uploads)
|
|
84
|
+
deletes = _sorted_unique(deletes)
|
|
85
|
+
submodules = {p for p, ok in subs.items() if ok and p in set(uploads)}
|
|
86
|
+
return ChangeSet(uploads=uploads, deletes=deletes, submodules=submodules)
|
gitftp/cli/__init__.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Command-line entry point: argv normalisation and exit-code mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from gitftp.cli.group import ACTIONS, build_group
|
|
11
|
+
from gitftp.cli.options import OPTIONAL_VALUE_OPTIONS, VALUE_OPTIONS, long_name
|
|
12
|
+
from gitftp.errors import Aborted, ExitCode, GitFtpError, MissingArgumentError
|
|
13
|
+
from gitftp.output import Level, Output
|
|
14
|
+
|
|
15
|
+
USAGE = "git-ftp <action> [<options>] [<url>]"
|
|
16
|
+
_PASSTHROUGH = {"-h", "--help", "--version"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def level_from_argv(argv: list[str]) -> Level:
|
|
20
|
+
level = Level.NORMAL
|
|
21
|
+
for tok in argv:
|
|
22
|
+
if tok in ("-n", "--silent"):
|
|
23
|
+
return Level.SILENT
|
|
24
|
+
if tok == "--verbose" or (tok.startswith("-v") and set(tok[1:]) == {"v"}):
|
|
25
|
+
count = 1 if tok == "--verbose" else len(tok) - 1
|
|
26
|
+
level = Level.TRACE if count >= 2 or level == Level.VERBOSE else Level.VERBOSE
|
|
27
|
+
return level
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_argv(argv: list[str]) -> list[str] | None:
|
|
31
|
+
"""Move the action to the front and make bare optional-value options explicit.
|
|
32
|
+
|
|
33
|
+
Returns ``None`` when there is nothing to run (bare ``git-ftp``).
|
|
34
|
+
"""
|
|
35
|
+
action: str | None = None
|
|
36
|
+
rest: list[str] = []
|
|
37
|
+
i = 0
|
|
38
|
+
n = len(argv)
|
|
39
|
+
while i < n:
|
|
40
|
+
tok = argv[i]
|
|
41
|
+
if tok == "--":
|
|
42
|
+
rest.extend(argv[i:])
|
|
43
|
+
break
|
|
44
|
+
if action is None and tok in ACTIONS:
|
|
45
|
+
action = tok
|
|
46
|
+
i += 1
|
|
47
|
+
continue
|
|
48
|
+
if tok in OPTIONAL_VALUE_OPTIONS:
|
|
49
|
+
nxt = argv[i + 1] if i + 1 < n else None
|
|
50
|
+
if nxt is None or nxt.startswith("-") or (action is None and nxt in ACTIONS):
|
|
51
|
+
rest.append(f"{long_name(tok)}=")
|
|
52
|
+
i += 1
|
|
53
|
+
continue
|
|
54
|
+
rest.extend([tok, nxt])
|
|
55
|
+
i += 2
|
|
56
|
+
continue
|
|
57
|
+
if tok in VALUE_OPTIONS:
|
|
58
|
+
rest.extend(argv[i : i + 2])
|
|
59
|
+
i += 2
|
|
60
|
+
continue
|
|
61
|
+
rest.append(tok)
|
|
62
|
+
i += 1
|
|
63
|
+
if action is None:
|
|
64
|
+
if not argv:
|
|
65
|
+
return None
|
|
66
|
+
if any(tok in _PASSTHROUGH for tok in argv):
|
|
67
|
+
return rest
|
|
68
|
+
raise MissingArgumentError("Action unknown.")
|
|
69
|
+
return [action, *rest]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: list[str] | None = None) -> int:
|
|
73
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
74
|
+
out = Output(level_from_argv(args))
|
|
75
|
+
try:
|
|
76
|
+
normalized = normalize_argv(args)
|
|
77
|
+
if normalized is None:
|
|
78
|
+
out.raw(USAGE)
|
|
79
|
+
return int(ExitCode.USAGE)
|
|
80
|
+
build_group().main(args=normalized, prog_name="git-ftp", standalone_mode=False, obj=out)
|
|
81
|
+
except click.exceptions.Exit as e:
|
|
82
|
+
return int(e.exit_code)
|
|
83
|
+
except (
|
|
84
|
+
click.NoSuchOption,
|
|
85
|
+
click.BadOptionUsage,
|
|
86
|
+
click.MissingParameter,
|
|
87
|
+
click.BadParameter,
|
|
88
|
+
) as e:
|
|
89
|
+
out.fatal(e.format_message())
|
|
90
|
+
return int(ExitCode.MISSING_ARGUMENTS)
|
|
91
|
+
except click.UsageError as e:
|
|
92
|
+
out.fatal(e.format_message())
|
|
93
|
+
return int(ExitCode.USAGE)
|
|
94
|
+
except click.Abort:
|
|
95
|
+
out.fatal("Interrupted.")
|
|
96
|
+
return int(ExitCode.INTERRUPTED)
|
|
97
|
+
except Aborted:
|
|
98
|
+
return int(ExitCode.OK)
|
|
99
|
+
except GitFtpError as e:
|
|
100
|
+
if e.message:
|
|
101
|
+
out.fatal(e.message)
|
|
102
|
+
return int(e.code)
|
|
103
|
+
except KeyboardInterrupt:
|
|
104
|
+
out.fatal("Interrupted.")
|
|
105
|
+
return int(ExitCode.INTERRUPTED)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
out.fatal(f"Unexpected error: {e}")
|
|
108
|
+
if out.tracing:
|
|
109
|
+
out.trace(traceback.format_exc())
|
|
110
|
+
return int(ExitCode.UNKNOWN)
|
|
111
|
+
return int(ExitCode.OK)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The ``add-scope`` action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from gitftp import config
|
|
11
|
+
from gitftp.cli.options import common_options, prepare
|
|
12
|
+
from gitftp.gitrepo import GitRepo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("add-scope", short_help="Store a URL (with credentials) under a scope name.")
|
|
16
|
+
@common_options
|
|
17
|
+
@click.argument("scope_name", metavar="SCOPE")
|
|
18
|
+
@click.argument("url", metavar="URL")
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def command(ctx: click.Context, /, scope_name: str, url: str, **kw: Any) -> None:
|
|
21
|
+
_opts, _out = prepare(ctx, kw)
|
|
22
|
+
repo = GitRepo.discover(Path.cwd())
|
|
23
|
+
config.add_scope(repo, scope_name, url)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The catchup action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from gitftp import deploy
|
|
10
|
+
from gitftp.cli.options import common_options, session_for, url_argument
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("catchup", short_help="Record the current commit as deployed without uploading.")
|
|
14
|
+
@common_options
|
|
15
|
+
@url_argument
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def command(ctx: click.Context, /, url: str | None, **kw: Any) -> None:
|
|
18
|
+
opts, session = session_for(ctx, url, kw)
|
|
19
|
+
deploy.run(deploy.Action.CATCHUP, session, deploy.DeployOptions.from_cli(opts))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The ``download`` action (native mirror, no lftp)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from gitftp import mirror
|
|
10
|
+
from gitftp.cli.options import common_options, session_for, url_argument
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("download", short_help="Download the remote files into the working tree.")
|
|
14
|
+
@common_options
|
|
15
|
+
@url_argument
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def command(ctx: click.Context, /, url: str | None, **kw: Any) -> None:
|
|
18
|
+
opts, session = session_for(ctx, url, kw)
|
|
19
|
+
mirror.run_download(session, mirror.MirrorOptions.from_cli(opts))
|
gitftp/cli/cmd_help.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The ``help`` action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from gitftp.cli.options import common_options
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command("help", short_help="Show this help.")
|
|
13
|
+
@common_options
|
|
14
|
+
@click.pass_context
|
|
15
|
+
def command(ctx: click.Context, /, **_kw: Any) -> None:
|
|
16
|
+
root = ctx.find_root()
|
|
17
|
+
click.echo(root.get_help())
|
|
18
|
+
click.echo()
|
|
19
|
+
for name in root.command.list_commands(root) if isinstance(root.command, click.Group) else []:
|
|
20
|
+
cmd = (
|
|
21
|
+
root.command.get_command(root, name) if isinstance(root.command, click.Group) else None
|
|
22
|
+
)
|
|
23
|
+
if cmd is not None and name not in ("help", "version"):
|
|
24
|
+
click.echo(f" {name:14s} {cmd.get_short_help_str(limit=80)}")
|
|
25
|
+
click.echo()
|
|
26
|
+
click.echo("Options accepted by every action:")
|
|
27
|
+
with click.Context(ctx.command, info_name="<action>") as sub:
|
|
28
|
+
formatter = sub.make_formatter()
|
|
29
|
+
ctx.command.format_options(sub, formatter)
|
|
30
|
+
click.echo(formatter.getvalue().rstrip())
|
gitftp/cli/cmd_init.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The init action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from gitftp import deploy
|
|
10
|
+
from gitftp.cli.options import common_options, session_for, url_argument
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("init", short_help="Upload all files and record the commit (first deployment).")
|
|
14
|
+
@common_options
|
|
15
|
+
@url_argument
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def command(ctx: click.Context, /, url: str | None, **kw: Any) -> None:
|
|
18
|
+
opts, session = session_for(ctx, url, kw)
|
|
19
|
+
deploy.run(deploy.Action.INIT, session, deploy.DeployOptions.from_cli(opts))
|
gitftp/cli/cmd_log.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The log action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from gitftp.cli.options import common_options, session_for, url_argument
|
|
10
|
+
from gitftp.errors import DownloadError, GitError
|
|
11
|
+
from gitftp.transport import registry
|
|
12
|
+
from gitftp.transport.base import RemoteNotFound
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("log", short_help="Run 'git log' on the deployed commit.")
|
|
16
|
+
@common_options
|
|
17
|
+
@url_argument
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def command(ctx: click.Context, /, url: str | None, **kw: Any) -> None:
|
|
20
|
+
_opts, session = session_for(ctx, url, kw)
|
|
21
|
+
registry.check_available(session.url.scheme)
|
|
22
|
+
try:
|
|
23
|
+
data = session.primary.get(session.deployed_sha1_file)
|
|
24
|
+
except (RemoteNotFound, DownloadError) as e:
|
|
25
|
+
raise DownloadError(f"Could not get uploaded log file. {e}") from e
|
|
26
|
+
sha = data.decode("utf-8", "replace").strip()
|
|
27
|
+
if not sha:
|
|
28
|
+
raise DownloadError("Could not get uploaded log file. It is empty.")
|
|
29
|
+
session.close()
|
|
30
|
+
if session.require_repo().log(sha) != 0:
|
|
31
|
+
raise GitError(f"git log {sha} failed.")
|
gitftp/cli/cmd_pull.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The ``pull`` action (native mirror, no lftp)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from gitftp import mirror
|
|
10
|
+
from gitftp.cli.options import common_options, session_for, url_argument
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("pull", short_help="Download remote changes into a commit and merge it.")
|
|
14
|
+
@common_options
|
|
15
|
+
@url_argument
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def command(ctx: click.Context, /, url: str | None, **kw: Any) -> None:
|
|
18
|
+
opts, session = session_for(ctx, url, kw)
|
|
19
|
+
mirror.run_pull(session, mirror.MirrorOptions.from_cli(opts))
|
gitftp/cli/cmd_push.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The push action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from gitftp import deploy
|
|
10
|
+
from gitftp.cli.options import common_options, session_for, url_argument
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command(
|
|
14
|
+
"push", short_help="Upload changed and delete removed files since the last deployment."
|
|
15
|
+
)
|
|
16
|
+
@common_options
|
|
17
|
+
@url_argument
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def command(ctx: click.Context, /, url: str | None, **kw: Any) -> None:
|
|
20
|
+
opts, session = session_for(ctx, url, kw)
|
|
21
|
+
deploy.run(deploy.Action.PUSH, session, deploy.DeployOptions.from_cli(opts))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The ``remove-scope`` action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from gitftp import config
|
|
11
|
+
from gitftp.cli.options import common_options, prepare
|
|
12
|
+
from gitftp.gitrepo import GitRepo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("remove-scope", short_help="Delete a scope from the git config.")
|
|
16
|
+
@common_options
|
|
17
|
+
@click.argument("scope_name", metavar="SCOPE")
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def command(ctx: click.Context, /, scope_name: str, **kw: Any) -> None:
|
|
20
|
+
_opts, out = prepare(ctx, kw)
|
|
21
|
+
repo = GitRepo.discover(Path.cwd())
|
|
22
|
+
config.remove_scope(repo, scope_name)
|
|
23
|
+
out.info(f"Successfully removed scope {scope_name}.")
|
gitftp/cli/cmd_show.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The show action."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from gitftp.cli.options import common_options, session_for, url_argument
|
|
10
|
+
from gitftp.errors import DownloadError, GitError
|
|
11
|
+
from gitftp.transport import registry
|
|
12
|
+
from gitftp.transport.base import RemoteNotFound
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("show", short_help="Run 'git show' on the deployed commit.")
|
|
16
|
+
@common_options
|
|
17
|
+
@url_argument
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def command(ctx: click.Context, /, url: str | None, **kw: Any) -> None:
|
|
20
|
+
_opts, session = session_for(ctx, url, kw)
|
|
21
|
+
registry.check_available(session.url.scheme)
|
|
22
|
+
try:
|
|
23
|
+
data = session.primary.get(session.deployed_sha1_file)
|
|
24
|
+
except (RemoteNotFound, DownloadError) as e:
|
|
25
|
+
raise DownloadError(f"Could not get uploaded log file. {e}") from e
|
|
26
|
+
sha = data.decode("utf-8", "replace").strip()
|
|
27
|
+
if not sha:
|
|
28
|
+
raise DownloadError("Could not get uploaded log file. It is empty.")
|
|
29
|
+
session.close()
|
|
30
|
+
if session.require_repo().show(sha) != 0:
|
|
31
|
+
raise GitError(f"git show {sha} failed.")
|