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/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
|