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.
gitftp/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m gitftp``."""
2
+
3
+ import sys
4
+
5
+ from gitftp.cli import main
6
+
7
+ sys.exit(main())
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.")