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/gitrepo.py ADDED
@@ -0,0 +1,293 @@
1
+ """Git access through the ``git`` binary.
2
+
3
+ Nothing here changes the working directory: every command receives ``cwd``.
4
+ Upstream ran ``set_syncroot`` before ``cd``-ing to the top level, which broke
5
+ invocations from a subdirectory; here paths are always resolved from
6
+ :attr:`GitRepo.root`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import tempfile
16
+ from collections.abc import Iterator, Sequence
17
+ from contextlib import contextmanager
18
+ from pathlib import Path
19
+
20
+ from gitftp.errors import GitError
21
+
22
+ MIN_GIT_VERSION = (1, 7, 0)
23
+
24
+
25
+ class UnknownCommit(Exception):
26
+ """A diff against a commit git does not know."""
27
+
28
+
29
+ class GitRunner:
30
+ """Run git commands in a directory (which need not be a repository)."""
31
+
32
+ def __init__(self, cwd: Path) -> None:
33
+ self.cwd = Path(cwd)
34
+
35
+ def run(
36
+ self,
37
+ *args: str,
38
+ input: bytes | None = None,
39
+ ok_codes: Sequence[int] = (0,),
40
+ inherit_stdio: bool = False,
41
+ ) -> subprocess.CompletedProcess[bytes]:
42
+ env = dict(os.environ)
43
+ env["LC_ALL"] = "C"
44
+ try:
45
+ proc = subprocess.run(
46
+ ["git", *args],
47
+ cwd=self.cwd,
48
+ input=input,
49
+ env=env,
50
+ stdin=None if inherit_stdio or input is not None else subprocess.DEVNULL,
51
+ stdout=None if inherit_stdio else subprocess.PIPE,
52
+ stderr=None if inherit_stdio else subprocess.PIPE,
53
+ check=False,
54
+ )
55
+ except FileNotFoundError as e:
56
+ raise GitError("git is not installed or not on PATH.") from e
57
+ if ok_codes and proc.returncode not in ok_codes:
58
+ err = (proc.stderr or b"").decode("utf-8", "replace").strip()
59
+ raise GitError(f"git {args[0]} failed: {err or proc.returncode}")
60
+ return proc
61
+
62
+ def out(self, *args: str, ok_codes: Sequence[int] = (0,)) -> str:
63
+ return self.run(*args, ok_codes=ok_codes).stdout.decode("utf-8", "surrogateescape")
64
+
65
+ def check_version(self) -> None:
66
+ text = self.out("--version")
67
+ m = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", text)
68
+ if not m:
69
+ return
70
+ ver = (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
71
+ if ver < MIN_GIT_VERSION:
72
+ wanted = ".".join(str(n) for n in MIN_GIT_VERSION)
73
+ raise GitError(f"Git is too old, {wanted} or higher supported only.")
74
+
75
+ # -- config ------------------------------------------------------------
76
+ def config_list(self, file: Path | None = None) -> dict[str, str | None]:
77
+ """``git config --list -z`` as a mapping; ``None`` marks a valueless key."""
78
+ args = ["config"]
79
+ if file is not None:
80
+ args += ["-f", str(file)]
81
+ args += ["--list", "-z"]
82
+ proc = self.run(*args, ok_codes=(0, 128))
83
+ result: dict[str, str | None] = {}
84
+ for item in proc.stdout.split(b"\0"):
85
+ if not item:
86
+ continue
87
+ text = item.decode("utf-8", "surrogateescape")
88
+ key, has_value, value = text.partition("\n")
89
+ result[_normalise_key(key)] = value if has_value else None
90
+ return result
91
+
92
+ def config_set(self, key: str, value: str, file: Path | None = None) -> None:
93
+ args = ["config"]
94
+ if file is not None:
95
+ args += ["-f", str(file)]
96
+ self.run(*args, "--", key, value)
97
+
98
+ def config_remove_section(self, section: str) -> bool:
99
+ proc = self.run("config", "--remove-section", section, ok_codes=())
100
+ return proc.returncode == 0
101
+
102
+ def config_get(self, key: str) -> str | None:
103
+ proc = self.run("config", "--get", key, ok_codes=(0, 1))
104
+ if proc.returncode != 0:
105
+ return None
106
+ return proc.stdout.decode("utf-8", "surrogateescape").rstrip("\n")
107
+
108
+
109
+ def _normalise_key(key: str) -> str:
110
+ """Lower-case section and key, keep the subsection (scope) case."""
111
+ parts = key.split(".")
112
+ if len(parts) >= 3:
113
+ return ".".join([parts[0].lower(), *parts[1:-1], parts[-1].lower()])
114
+ return key.lower()
115
+
116
+
117
+ class GitRepo(GitRunner):
118
+ """A git working tree."""
119
+
120
+ def __init__(self, root: Path) -> None:
121
+ super().__init__(Path(root).absolute())
122
+ self.root = Path(root).absolute()
123
+
124
+ @classmethod
125
+ def discover(cls, cwd: Path) -> GitRepo:
126
+ runner = GitRunner(cwd)
127
+ proc = runner.run("rev-parse", "--show-toplevel", ok_codes=(0, 128))
128
+ top = proc.stdout.decode("utf-8", "surrogateescape").strip()
129
+ if proc.returncode != 0 or not top:
130
+ raise GitError("Not a Git project? Exiting...")
131
+ return cls(Path(top))
132
+
133
+ @classmethod
134
+ def init(cls, path: Path) -> GitRepo:
135
+ proc = GitRunner(path).run("init", ok_codes=())
136
+ if proc.returncode != 0:
137
+ raise GitError("Error initialising Git repository.")
138
+ return cls(path)
139
+
140
+ # -- state -------------------------------------------------------------
141
+ def head_sha(self) -> str:
142
+ return self.out("log", "-n", "1", "--pretty=format:%H").strip()
143
+
144
+ def current_branch(self) -> str:
145
+ proc = self.run("symbolic-ref", "-q", "--short", "HEAD", ok_codes=(0, 1))
146
+ if proc.returncode == 0:
147
+ return proc.stdout.decode("utf-8", "surrogateescape").strip()
148
+ return self.head_sha()
149
+
150
+ def checkout(self, ref: str) -> bool:
151
+ proc = self.run("checkout", "-q", ref, ok_codes=())
152
+ return proc.returncode == 0
153
+
154
+ def is_dirty(self) -> bool:
155
+ return bool(self.out("status", "-uno", "--porcelain").strip())
156
+
157
+ def has_any_changes(self) -> bool:
158
+ return bool(self.out("status", "--porcelain").strip())
159
+
160
+ def empty_tree(self) -> str:
161
+ return self.out("hash-object", "-t", "tree", os.devnull).strip()
162
+
163
+ # -- file lists (NUL separated, repo-relative, forward slashes) --------
164
+ def _z(self, *args: str, ok_codes: Sequence[int] = (0,)) -> list[str]:
165
+ data = self.run(*args, ok_codes=ok_codes).stdout
166
+ return [p.decode("utf-8", "surrogateescape") for p in data.split(b"\0") if p]
167
+
168
+ def ls_files(self, prefix: str = "") -> list[str]:
169
+ return self._z("ls-files", "-z", "--", prefix or ".")
170
+
171
+ def diff_names(self, base: str, diff_filter: str, prefix: str = "") -> list[str]:
172
+ proc = self.run(
173
+ "diff",
174
+ "--name-only",
175
+ "--no-renames",
176
+ f"--diff-filter={diff_filter}",
177
+ "-z",
178
+ base,
179
+ "--",
180
+ prefix or ".",
181
+ ok_codes=(),
182
+ )
183
+ if proc.returncode != 0:
184
+ raise UnknownCommit(base)
185
+ return [p.decode("utf-8", "surrogateescape") for p in proc.stdout.split(b"\0") if p]
186
+
187
+ def diff_quiet_changed(self, base: str, path: str) -> bool:
188
+ """``git diff --quiet base -- path``; any non-zero status counts as changed."""
189
+ proc = self.run("diff", "--quiet", base, "--", path, ok_codes=())
190
+ return proc.returncode != 0
191
+
192
+ def diff_names_between(self, a: str, b: str | None = None) -> list[str]:
193
+ args = ["diff", "--name-only", "-z", a]
194
+ if b:
195
+ args.append(b)
196
+ return self._z(*args)
197
+
198
+ def submodules(self, prefix: str = "") -> dict[str, bool]:
199
+ """Submodule paths under ``prefix`` mapped to whether they are initialised."""
200
+ if not (self.root / ".gitmodules").is_file():
201
+ return {}
202
+ args = ["submodule", "status"]
203
+ if prefix:
204
+ args += ["--", prefix]
205
+ proc = self.run(*args, ok_codes=(0, 1, 128))
206
+ result: dict[str, bool] = {}
207
+ for line in proc.stdout.decode("utf-8", "surrogateescape").splitlines():
208
+ if not line.strip():
209
+ continue
210
+ initialised = not line.startswith("-")
211
+ parts = line.strip().lstrip("-+U").split()
212
+ if parts and len(parts) >= 2:
213
+ result[parts[1]] = initialised
214
+ return result
215
+
216
+ def ignored(self, paths: list[str]) -> set[str]:
217
+ """The subset of ``paths`` (repo-relative) that git ignores."""
218
+ if not paths:
219
+ return set()
220
+ data = b"".join(p.encode("utf-8", "surrogateescape") + b"\0" for p in paths)
221
+ proc = self.run("check-ignore", "-z", "--stdin", input=data, ok_codes=(0, 1))
222
+ return {p.decode("utf-8", "surrogateescape") for p in proc.stdout.split(b"\0") if p}
223
+
224
+ def hooks_dir(self) -> Path:
225
+ path = self.out("rev-parse", "--git-path", "hooks").strip()
226
+ p = Path(path)
227
+ return p if p.is_absolute() else self.root / p
228
+
229
+ # -- mutations used by pull/snapshot -----------------------------------
230
+ def stash_push_untracked(self) -> bool:
231
+ """``git stash -u``; True when something was actually stashed."""
232
+ proc = self.run("stash", "push", "-u", ok_codes=(0, 1))
233
+ return proc.returncode == 0 and b"No local changes to save" not in proc.stdout
234
+
235
+ def stash_pop(self) -> None:
236
+ self.run("stash", "pop", "-q")
237
+
238
+ @contextmanager
239
+ def temporary_worktree(self, ref: str) -> Iterator[Path]:
240
+ """Check ``ref`` out into a throwaway detached worktree, then remove it.
241
+
242
+ The worktree shares the object store, so only the working copy is written
243
+ to disk. Reading upload contents from it isolates a deploy from edits made
244
+ to the live working tree while the transfer is running.
245
+ """
246
+ parent = Path(tempfile.mkdtemp(prefix="git-ftp-worktree-"))
247
+ tree = parent / "tree" # must not pre-exist: git worktree add creates it
248
+ try:
249
+ self.run("worktree", "add", "--detach", "--quiet", str(tree), ref)
250
+ except GitError:
251
+ shutil.rmtree(parent, ignore_errors=True)
252
+ raise
253
+ try:
254
+ yield tree
255
+ finally:
256
+ self.run("worktree", "remove", "--force", str(tree), ok_codes=())
257
+ self.run("worktree", "prune", ok_codes=())
258
+ shutil.rmtree(parent, ignore_errors=True)
259
+
260
+ def add_all(self) -> None:
261
+ self.run("add", "--all")
262
+
263
+ def add_dot(self) -> None:
264
+ self.run("add", ".")
265
+
266
+ def commit(
267
+ self, subject: str, body: str | None = None, quiet: bool = True, allow_empty: bool = False
268
+ ) -> bool:
269
+ args = ["commit", "-m", subject]
270
+ if allow_empty:
271
+ args.append("--allow-empty")
272
+ if body:
273
+ args += ["-m", body]
274
+ if quiet:
275
+ args.append("-q")
276
+ proc = self.run(*args, ok_codes=())
277
+ return proc.returncode == 0
278
+
279
+ def diff_head_name_status(self) -> str:
280
+ return self.out("diff", "HEAD", "--name-status")
281
+
282
+ def merge(self, sha: str, no_commit: bool) -> int:
283
+ args = ["merge"]
284
+ if no_commit:
285
+ args += ["--no-commit", "--no-ff"]
286
+ args.append(sha)
287
+ return self.run(*args, ok_codes=(), inherit_stdio=True).returncode
288
+
289
+ def show(self, sha: str) -> int:
290
+ return self.run("show", sha, ok_codes=(), inherit_stdio=True).returncode
291
+
292
+ def log(self, sha: str) -> int:
293
+ return self.run("log", sha, ok_codes=(), inherit_stdio=True).returncode
gitftp/hooks.py ADDED
@@ -0,0 +1,30 @@
1
+ """pre-ftp-push and post-ftp-push hooks (upstream's experimental interface)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from collections.abc import Sequence
8
+
9
+ from gitftp.gitrepo import GitRepo
10
+ from gitftp.output import Output
11
+
12
+ PRE_PUSH = "pre-ftp-push"
13
+ POST_PUSH = "post-ftp-push"
14
+
15
+
16
+ def run_hook(
17
+ repo: GitRepo, name: str, args: Sequence[str], stdin: bytes, out: Output
18
+ ) -> int | None:
19
+ """Run ``<hooks dir>/<name>`` if it exists and is executable; None when absent."""
20
+ path = repo.hooks_dir() / name
21
+ if not path.is_file() or not os.access(path, os.X_OK):
22
+ return None
23
+ out.debug(f"Running hook {name}.")
24
+ proc = subprocess.run(
25
+ [str(path), *args],
26
+ cwd=repo.root,
27
+ input=stdin,
28
+ check=False,
29
+ )
30
+ return proc.returncode
gitftp/ignore.py ADDED
@@ -0,0 +1,83 @@
1
+ """.git-ftp-ignore: shell-glob patterns matched against the whole git path.
2
+
3
+ Upstream matches with a bash ``case`` statement, so ``*`` and ``?`` also match
4
+ ``/`` and the pattern must cover the entire path (``config/*`` ignores
5
+ ``config/a/b`` but ``foo.txt`` does not ignore ``dir/foo.txt``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Iterable
12
+ from pathlib import Path
13
+
14
+ IGNORE_FILE = ".git-ftp-ignore"
15
+
16
+
17
+ def glob_to_regex(pattern: str) -> re.Pattern[str]:
18
+ """Translate a bash ``case`` glob into an anchored regex."""
19
+ i, n = 0, len(pattern)
20
+ out = ["^"]
21
+ while i < n:
22
+ c = pattern[i]
23
+ i += 1
24
+ if c == "*":
25
+ out.append(".*")
26
+ elif c == "?":
27
+ out.append(".")
28
+ elif c == "\\" and i < n:
29
+ out.append(re.escape(pattern[i]))
30
+ i += 1
31
+ elif c == "[":
32
+ j = i
33
+ if j < n and pattern[j] in "!^":
34
+ j += 1
35
+ if j < n and pattern[j] == "]":
36
+ j += 1
37
+ while j < n and pattern[j] != "]":
38
+ j += 1
39
+ if j >= n:
40
+ out.append(re.escape(c))
41
+ else:
42
+ body = pattern[i:j]
43
+ i = j + 1
44
+ if body and body[0] in "!^":
45
+ body = "^" + body[1:]
46
+ body = body.replace("\\", "\\\\")
47
+ out.append(f"[{body}]")
48
+ else:
49
+ out.append(re.escape(c))
50
+ out.append("$")
51
+ return re.compile("".join(out), re.DOTALL)
52
+
53
+
54
+ class IgnoreRules:
55
+ def __init__(self, patterns: Iterable[str] = ()) -> None:
56
+ self.patterns = [p for p in patterns if p]
57
+ self._regexes = [glob_to_regex(p) for p in self.patterns]
58
+
59
+ @classmethod
60
+ def parse(cls, text: str) -> IgnoreRules:
61
+ patterns = []
62
+ for line in text.splitlines():
63
+ line = line.rstrip("\r")
64
+ if not line.strip() or line.startswith("#"):
65
+ continue
66
+ patterns.append(line)
67
+ return cls(patterns)
68
+
69
+ @classmethod
70
+ def load(cls, root: Path) -> IgnoreRules:
71
+ path = root / IGNORE_FILE
72
+ if not path.is_file():
73
+ return cls()
74
+ return cls.parse(path.read_text(encoding="utf-8", errors="surrogateescape"))
75
+
76
+ def __len__(self) -> int:
77
+ return len(self.patterns)
78
+
79
+ def matches(self, path: str) -> bool:
80
+ return any(r.match(path) for r in self._regexes)
81
+
82
+ def filter(self, paths: Iterable[str]) -> list[str]:
83
+ return [p for p in paths if not self.matches(p)]
gitftp/include.py ADDED
@@ -0,0 +1,94 @@
1
+ """.git-ftp-include: upload untracked files.
2
+
3
+ Formats:
4
+ !target always upload ``target``
5
+ target:source upload ``target`` when tracked ``source`` changed
6
+ target:/source ``source`` is relative to the repository root even with --syncroot
7
+
8
+ A ``target`` that is a directory expands to every file below it. A target that
9
+ no longer exists locally is deleted remotely (directories excepted).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from gitftp.gitrepo import GitRepo
19
+ from gitftp.output import Output
20
+
21
+ INCLUDE_FILE = ".git-ftp-include"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class IncludeRule:
26
+ target: str
27
+ source: str | None
28
+ always: bool
29
+
30
+
31
+ def parse_rules(text: str) -> list[IncludeRule]:
32
+ rules = []
33
+ for raw in text.splitlines():
34
+ line = raw.rstrip("\r")
35
+ if not line.strip() or line.startswith("#"):
36
+ continue
37
+ if line.startswith("!"):
38
+ rules.append(IncludeRule(target=line[1:], source=None, always=True))
39
+ elif ":" in line:
40
+ target, _, source = line.partition(":")
41
+ rules.append(IncludeRule(target=target, source=source, always=False))
42
+ return rules
43
+
44
+
45
+ def load_rules(root: Path) -> list[IncludeRule]:
46
+ path = root / INCLUDE_FILE
47
+ if not path.is_file():
48
+ return []
49
+ return parse_rules(path.read_text(encoding="utf-8", errors="surrogateescape"))
50
+
51
+
52
+ def resolve_source(source: str, syncroot: str) -> str:
53
+ if source.startswith("/"):
54
+ return source.lstrip("/")
55
+ return f"{syncroot}{source}"
56
+
57
+
58
+ def _walk_files(root: Path, target: str) -> list[str]:
59
+ base = root / target
60
+ files = []
61
+ for dirpath, _dirs, names in os.walk(base):
62
+ for name in names:
63
+ rel = Path(dirpath, name).relative_to(root).as_posix()
64
+ files.append(rel)
65
+ return sorted(files)
66
+
67
+
68
+ def expand(
69
+ rules: list[IncludeRule],
70
+ repo: GitRepo,
71
+ syncroot: str,
72
+ against: str,
73
+ out: Output,
74
+ ) -> tuple[list[str], list[str]]:
75
+ """Return (uploads, deletes) contributed by the include rules."""
76
+ uploads: list[str] = []
77
+ deletes: list[str] = []
78
+ for rule in rules:
79
+ if not rule.always:
80
+ assert rule.source is not None
81
+ source = resolve_source(rule.source, syncroot)
82
+ if not repo.diff_quiet_changed(against, source):
83
+ continue
84
+ target = rule.target
85
+ local = repo.root / target
86
+ if local.is_dir():
87
+ uploads.extend(_walk_files(repo.root, target.rstrip("/")))
88
+ elif local.is_file():
89
+ uploads.append(target)
90
+ elif target.endswith("/"):
91
+ out.debug(f"Deletion of directory {target} is not supported.")
92
+ else:
93
+ deletes.append(target)
94
+ return uploads, deletes
gitftp/lock.py ADDED
@@ -0,0 +1,94 @@
1
+ """The remote lock file ``git-ftp.lck``.
2
+
3
+ Content: ``<sha>\\n<user>@<host> on <RFC 2822 date>``. Upstream wrote a literal
4
+ backslash-n (``echo`` without ``-e``); both forms are accepted when reading.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import getpass
10
+ import socket
11
+ from datetime import datetime, timezone
12
+ from email.utils import format_datetime
13
+
14
+ from gitftp.errors import RemoteLockedError, UploadError
15
+ from gitftp.output import Output
16
+ from gitftp.transport.base import RemoteNotFound, Transport
17
+
18
+ LOCK_FILE = "git-ftp.lck"
19
+
20
+
21
+ def parse(data: bytes) -> tuple[str, str]:
22
+ """Return (sha, holder) from a lock file, tolerant of upstream's literal ``\\n``."""
23
+ text = data.decode("utf-8", "replace")
24
+ if "\n" not in text and "\\n" in text:
25
+ text = text.replace("\\n", "\n", 1)
26
+ lines = text.splitlines()
27
+ sha = lines[0].strip() if lines else ""
28
+ holder = lines[1].strip() if len(lines) > 1 else ""
29
+ return sha, holder
30
+
31
+
32
+ def holder_message(now: datetime | None = None) -> str:
33
+ when = now or datetime.now(timezone.utc)
34
+ try:
35
+ user = getpass.getuser()
36
+ except Exception:
37
+ user = "unknown"
38
+ return f"{user}@{socket.getfqdn()} on {format_datetime(when)}"
39
+
40
+
41
+ class RemoteLock:
42
+ def __init__(
43
+ self,
44
+ transport: Transport,
45
+ local_sha: str,
46
+ *,
47
+ enabled: bool,
48
+ force: bool,
49
+ dry_run: bool,
50
+ out: Output,
51
+ ) -> None:
52
+ self.transport = transport
53
+ self.local_sha = local_sha
54
+ self.enabled = enabled
55
+ self.force = force
56
+ self.dry_run = dry_run
57
+ self.out = out
58
+ self.held = False
59
+
60
+ def check(self) -> None:
61
+ self.out.debug("Checking remote lock.")
62
+ try:
63
+ data = self.transport.get(LOCK_FILE)
64
+ except RemoteNotFound:
65
+ return
66
+ sha, holder = parse(data)
67
+ if sha and sha != self.local_sha:
68
+ raise RemoteLockedError(f"Remote locked by {holder}.")
69
+
70
+ def acquire(self) -> None:
71
+ """Check and write the lock; only with ``--lock``, as upstream."""
72
+ if not self.enabled:
73
+ return
74
+ if not self.force:
75
+ self.check()
76
+ if self.dry_run:
77
+ return
78
+ self.out.debug("Creating remote lock.")
79
+ content = f"{self.local_sha}\n{holder_message()}\n".encode()
80
+ try:
81
+ self.transport.put_bytes(content, LOCK_FILE)
82
+ except UploadError as e:
83
+ raise UploadError(f"Could not upload lock file. {e}") from e
84
+ self.held = True
85
+
86
+ def release(self) -> None:
87
+ if not self.held:
88
+ return
89
+ self.out.debug("Releasing remote lock.")
90
+ try:
91
+ self.transport.delete(LOCK_FILE)
92
+ except Exception as e:
93
+ self.out.warn(f"Could not remove remote lock: {e}")
94
+ self.held = False