gitacross 1.0.0__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.
- gitacross/__init__.py +34 -0
- gitacross/__main__.py +6 -0
- gitacross/config.py +218 -0
- gitacross/git.py +342 -0
- gitacross/gitea.py +9 -0
- gitacross/github.py +9 -0
- gitacross/linter.py +1105 -0
- gitacross/main.py +360 -0
- gitacross/providers/__init__.py +44 -0
- gitacross/providers/base.py +230 -0
- gitacross/providers/gitea.py +170 -0
- gitacross/providers/github.py +120 -0
- gitacross/renderer.py +195 -0
- gitacross/retry.py +23 -0
- gitacross/source.py +297 -0
- gitacross/state.py +67 -0
- gitacross/target.py +159 -0
- gitacross-1.0.0.dist-info/METADATA +318 -0
- gitacross-1.0.0.dist-info/RECORD +23 -0
- gitacross-1.0.0.dist-info/WHEEL +5 -0
- gitacross-1.0.0.dist-info/entry_points.txt +2 -0
- gitacross-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitacross-1.0.0.dist-info/top_level.txt +1 -0
gitacross/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""GitAcross – mirror releases and git commits across platforms."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .config import Config, ProjectConfig
|
|
6
|
+
from .linter import (
|
|
7
|
+
ConfigFixer,
|
|
8
|
+
ConfigLinter,
|
|
9
|
+
FixIssue,
|
|
10
|
+
FixReport,
|
|
11
|
+
LintIssue,
|
|
12
|
+
LintReport,
|
|
13
|
+
LintSeverity,
|
|
14
|
+
fix_config,
|
|
15
|
+
lint_config,
|
|
16
|
+
)
|
|
17
|
+
from .main import sync_project, main
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
"Config",
|
|
22
|
+
"ProjectConfig",
|
|
23
|
+
"ConfigFixer",
|
|
24
|
+
"ConfigLinter",
|
|
25
|
+
"FixIssue",
|
|
26
|
+
"FixReport",
|
|
27
|
+
"LintIssue",
|
|
28
|
+
"LintReport",
|
|
29
|
+
"LintSeverity",
|
|
30
|
+
"fix_config",
|
|
31
|
+
"lint_config",
|
|
32
|
+
"sync_project",
|
|
33
|
+
"main",
|
|
34
|
+
]
|
gitacross/__main__.py
ADDED
gitacross/config.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
_VALID_PROJECT_KEYS = {
|
|
9
|
+
"name",
|
|
10
|
+
"enabled",
|
|
11
|
+
"source",
|
|
12
|
+
"target",
|
|
13
|
+
"renderer",
|
|
14
|
+
"retry",
|
|
15
|
+
"preserve_description",
|
|
16
|
+
"preserve_release_description",
|
|
17
|
+
"sync_assets",
|
|
18
|
+
"preserve_assets",
|
|
19
|
+
"include_assets",
|
|
20
|
+
"stream_assets",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_KNOWN_SOURCE_KEYS = {
|
|
24
|
+
"mode": "source",
|
|
25
|
+
"sync_from": "source",
|
|
26
|
+
"include_prereleases": "source",
|
|
27
|
+
"include_drafts": "source",
|
|
28
|
+
"tag_pattern": "source",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_KNOWN_ENDPOINT_KEYS = {
|
|
32
|
+
"repo": "source or target",
|
|
33
|
+
"api": "source or target",
|
|
34
|
+
"token": "source or target",
|
|
35
|
+
"branch": "source or target",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Config:
|
|
40
|
+
def __init__(self, path):
|
|
41
|
+
with open(path) as f:
|
|
42
|
+
raw = yaml.safe_load(f)
|
|
43
|
+
if isinstance(raw, list):
|
|
44
|
+
# Flat list at top level: [ {name:..., source:..., ...} ]
|
|
45
|
+
raw = {"projects": raw}
|
|
46
|
+
elif raw is None:
|
|
47
|
+
raw = {"projects": []}
|
|
48
|
+
self.projects = [ProjectConfig(p) for p in raw.get("projects", [])]
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_path(cls, path):
|
|
52
|
+
return cls(path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _EndpointConfig:
|
|
56
|
+
"""Config for a source or target endpoint.
|
|
57
|
+
|
|
58
|
+
Fields shared by all types: type
|
|
59
|
+
Remote (gitea/github): repo, api, token
|
|
60
|
+
Local: path, tag_pattern/branch
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, raw, is_source):
|
|
64
|
+
self.type = raw.get("type", "gitea" if is_source else "github")
|
|
65
|
+
|
|
66
|
+
# Remote endpoint fields
|
|
67
|
+
self.repo = raw.get("repo", "")
|
|
68
|
+
self.api = raw.get("api", "")
|
|
69
|
+
self.token = _resolve_token(raw.get("token", ""))
|
|
70
|
+
|
|
71
|
+
# Local endpoint fields
|
|
72
|
+
self.path = raw.get("path", "")
|
|
73
|
+
self.tag_pattern = raw.get("tag_pattern", "*") if is_source else None
|
|
74
|
+
|
|
75
|
+
# Branch (target only by default; also used in source commit-mode to pick branch tip)
|
|
76
|
+
self.branch = raw.get("branch", "main" if not is_source else "")
|
|
77
|
+
|
|
78
|
+
# Source-side release filtering (remote only, ignored for local)
|
|
79
|
+
self.include_prereleases = raw.get("include_prereleases", False)
|
|
80
|
+
self.include_drafts = raw.get("include_drafts", False)
|
|
81
|
+
|
|
82
|
+
# Optional: only sync releases from this tag onwards (no backfilling needed)
|
|
83
|
+
# In commit mode this should be a commit SHA instead of a tag name.
|
|
84
|
+
self.sync_from = raw.get("sync_from", "")
|
|
85
|
+
|
|
86
|
+
# Release list source (remote sources): "release" (API releases,
|
|
87
|
+
# default), "tag" (git tags treated as releases), or "commit"
|
|
88
|
+
# (sync latest HEAD commit; sync_from must be a commit SHA).
|
|
89
|
+
self.mode = raw.get("mode", "release")
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_remote(self):
|
|
93
|
+
return self.type in ("gitea", "github")
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def owner(self):
|
|
97
|
+
return self.repo.split("/")[0] if "/" in self.repo else ""
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def clone_url(self):
|
|
101
|
+
"""HTTPS clone URL with token embedded for auth."""
|
|
102
|
+
if not self.api or not self.repo:
|
|
103
|
+
return ""
|
|
104
|
+
raw_host = self.api.split("://")[1].split("/")[0] if "://" in self.api else self.api
|
|
105
|
+
# GitHub's API lives at api.github.com, but its git host is github.com.
|
|
106
|
+
# Gitea's API typically lives on the same host as git, so no transform needed.
|
|
107
|
+
host = "github.com" if raw_host == "api.github.com" else raw_host
|
|
108
|
+
return f"https://{self.owner}:{self.token}@{host}/{self.repo}.git"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ProjectConfig:
|
|
112
|
+
def __init__(self, raw):
|
|
113
|
+
self.name = raw["name"]
|
|
114
|
+
self.enabled = bool(raw.get("enabled", True))
|
|
115
|
+
raw_source = raw.get("source", {})
|
|
116
|
+
raw_target = raw.get("target", {})
|
|
117
|
+
self.source = _EndpointConfig(raw_source, is_source=True)
|
|
118
|
+
self.target = _EndpointConfig(raw_target, is_source=False)
|
|
119
|
+
self.renderer = _RendererConfig(raw.get("renderer", {}))
|
|
120
|
+
self.retry = _RetryConfig(raw.get("retry", {}))
|
|
121
|
+
|
|
122
|
+
# Check for misplaced or unknown keys at the project level
|
|
123
|
+
for k in raw:
|
|
124
|
+
if k in _VALID_PROJECT_KEYS:
|
|
125
|
+
continue
|
|
126
|
+
if k in _KNOWN_SOURCE_KEYS:
|
|
127
|
+
logger.warning(
|
|
128
|
+
"Project '%s': '%s' was specified at the project level, but must be configured under '%s:' (e.g. %s.%s: %s).",
|
|
129
|
+
self.name,
|
|
130
|
+
k,
|
|
131
|
+
_KNOWN_SOURCE_KEYS[k],
|
|
132
|
+
_KNOWN_SOURCE_KEYS[k],
|
|
133
|
+
k,
|
|
134
|
+
raw[k],
|
|
135
|
+
)
|
|
136
|
+
elif k in _KNOWN_ENDPOINT_KEYS:
|
|
137
|
+
logger.warning(
|
|
138
|
+
"Project '%s': '%s' was specified at the project level, but belongs under '%s:'.",
|
|
139
|
+
self.name,
|
|
140
|
+
k,
|
|
141
|
+
_KNOWN_ENDPOINT_KEYS[k],
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
logger.warning(
|
|
145
|
+
"Project '%s': unrecognized configuration key '%s'.",
|
|
146
|
+
self.name,
|
|
147
|
+
k,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# preserve_description — single cascade: project → source → target → True
|
|
151
|
+
# Supports aliases: preserve_release_description (legacy)
|
|
152
|
+
self.preserve_description = _cascade(
|
|
153
|
+
raw, raw_source, raw_target,
|
|
154
|
+
keys=["preserve_description", "preserve_release_description"],
|
|
155
|
+
default=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# sync_assets — single cascade: project → source → target → False
|
|
159
|
+
# Supports aliases: preserve_assets, include_assets (legacy)
|
|
160
|
+
self.sync_assets = _cascade(
|
|
161
|
+
raw, raw_source, raw_target,
|
|
162
|
+
keys=["sync_assets", "preserve_assets", "include_assets"],
|
|
163
|
+
default=False,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# stream_assets — project-level only (no endpoint-level alias)
|
|
167
|
+
# When True, asset uploads stream from disk instead of buffering in RAM.
|
|
168
|
+
# Default is False (RAM) to preserve existing behaviour.
|
|
169
|
+
self.stream_assets = bool(raw.get("stream_assets", False))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _AuthorConfig:
|
|
173
|
+
def __init__(self, raw):
|
|
174
|
+
self.name = raw.get("name", "")
|
|
175
|
+
self.email = raw.get("email", "")
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def enabled(self):
|
|
179
|
+
return bool(self.name) or bool(self.email)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _RendererConfig:
|
|
183
|
+
def __init__(self, raw):
|
|
184
|
+
self.ignore = raw.get("ignore", [])
|
|
185
|
+
self.operations = raw.get("operations", [])
|
|
186
|
+
raw_author = raw.get("author")
|
|
187
|
+
self.author = _AuthorConfig(raw_author) if raw_author else _AuthorConfig({})
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _RetryConfig:
|
|
191
|
+
def __init__(self, raw):
|
|
192
|
+
self.max_attempts = raw.get("max_attempts", 3)
|
|
193
|
+
self.backoff_seconds = raw.get("backoff_seconds", 2)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Helpers
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def _cascade(raw_project, raw_source, raw_target, keys, default):
|
|
201
|
+
"""Return the first value found for any of *keys* across project, source, target.
|
|
202
|
+
|
|
203
|
+
Lookup order: project-level first (highest priority), then source-level,
|
|
204
|
+
then target-level, then *default*. All alias keys are checked at each
|
|
205
|
+
level before moving to the next — so a project-level alias wins over a
|
|
206
|
+
source-level primary key.
|
|
207
|
+
"""
|
|
208
|
+
for raw in (raw_project, raw_source, raw_target):
|
|
209
|
+
for k in keys:
|
|
210
|
+
if k in raw:
|
|
211
|
+
return raw[k]
|
|
212
|
+
return default
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _resolve_token(val):
|
|
216
|
+
if val.startswith("${") and val.endswith("}"):
|
|
217
|
+
return os.environ.get(val[2:-1], "")
|
|
218
|
+
return val
|
gitacross/git.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import tarfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# scheme://user:token@host — mask the token when logging URLs
|
|
12
|
+
_TOKEN_RE = re.compile(r"(://[^/@\s:]+):[^/@\s]+@")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _redact(value):
|
|
16
|
+
"""Mask credentials embedded in URLs (scheme://user:token@ → scheme://user:***@)."""
|
|
17
|
+
return _TOKEN_RE.sub(r"\1:***@", value)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _log_git_stderr(command, returncode, stderr, level):
|
|
21
|
+
"""Log a failed git command's stderr (decoded, redacted)."""
|
|
22
|
+
if not stderr:
|
|
23
|
+
return
|
|
24
|
+
msg = stderr.strip()
|
|
25
|
+
if not isinstance(msg, str):
|
|
26
|
+
msg = msg.decode(errors="replace")
|
|
27
|
+
if msg:
|
|
28
|
+
logger.log(
|
|
29
|
+
level,
|
|
30
|
+
"git %s failed (exit %d): %s",
|
|
31
|
+
command,
|
|
32
|
+
returncode,
|
|
33
|
+
_redact(msg),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _git(*args, check=True, input_data=None, text=True, env=None):
|
|
38
|
+
cmd = ["git"] + [str(a) for a in args]
|
|
39
|
+
logger.debug("> git %s", _redact(" ".join(str(a) for a in args)))
|
|
40
|
+
try:
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
cmd,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=text,
|
|
45
|
+
check=check,
|
|
46
|
+
input=input_data,
|
|
47
|
+
env=env,
|
|
48
|
+
)
|
|
49
|
+
except subprocess.CalledProcessError as e:
|
|
50
|
+
_log_git_stderr(args[0], e.returncode, e.stderr, level=logging.ERROR)
|
|
51
|
+
# Sanitize the command shown in the traceback (it may contain a token URL)
|
|
52
|
+
for i, part in enumerate(e.cmd):
|
|
53
|
+
if isinstance(part, str):
|
|
54
|
+
e.cmd[i] = _redact(part)
|
|
55
|
+
raise
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
# check=False path — callers inspect the return code themselves
|
|
58
|
+
_log_git_stderr(args[0], result.returncode, result.stderr, level=logging.DEBUG)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GitRepo:
|
|
63
|
+
"""A git repository for release operations.
|
|
64
|
+
|
|
65
|
+
Works with both bare mirrors (cloned from remote) and local repos.
|
|
66
|
+
Uses `--git-dir` + `--work-tree` for committing from a temp directory,
|
|
67
|
+
avoiding any git worktree management.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, git_dir, is_bare=False):
|
|
71
|
+
self.git_dir = Path(git_dir)
|
|
72
|
+
self.is_bare = is_bare
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def ensure_mirror(cls, url, dest):
|
|
76
|
+
"""Clone a bare mirror or update an existing one.
|
|
77
|
+
|
|
78
|
+
Fetches with --prune: a failed push leaves locally created refs that
|
|
79
|
+
the remote never accepted; pruning resets the mirror to the remote's
|
|
80
|
+
actual state so the next run re-syncs instead of treating those
|
|
81
|
+
stale refs as already-pushed.
|
|
82
|
+
"""
|
|
83
|
+
path = Path(dest)
|
|
84
|
+
if path.exists():
|
|
85
|
+
# Update remote URL *before* fetching so token rotation takes effect
|
|
86
|
+
current = _git("-C", str(path), "remote", "get-url", "origin", check=False)
|
|
87
|
+
if current.returncode == 0 and current.stdout.strip() != url:
|
|
88
|
+
_git("-C", str(path), "remote", "set-url", "origin", url)
|
|
89
|
+
logger.info("Updated remote URL for mirror at %s", dest)
|
|
90
|
+
_git("-C", str(path), "fetch", "--tags", "--prune", "origin")
|
|
91
|
+
logger.info("Updated mirror at %s", dest)
|
|
92
|
+
else:
|
|
93
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
_git("clone", "--mirror", url, str(path))
|
|
95
|
+
logger.info("Cloned mirror from %s", _redact(url))
|
|
96
|
+
# Ensure author identity for automated commits
|
|
97
|
+
_git("-C", str(path), "config", "user.name", "GitAcross")
|
|
98
|
+
_git("-C", str(path), "config", "user.email", "sync@gitacross")
|
|
99
|
+
return cls(path, is_bare=True)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def local(cls, path):
|
|
103
|
+
"""Open an existing local git repository."""
|
|
104
|
+
p = Path(path)
|
|
105
|
+
result = _git("-C", str(p), "rev-parse", "--git-dir")
|
|
106
|
+
git_dir = p / result.stdout.strip()
|
|
107
|
+
if not git_dir.exists():
|
|
108
|
+
raise ValueError(f"Not a git repository: {path}")
|
|
109
|
+
return cls(git_dir, is_bare=False)
|
|
110
|
+
|
|
111
|
+
def _g(self, *args, text=True, env=None, **kwargs):
|
|
112
|
+
"""Run git command with --git-dir set."""
|
|
113
|
+
return _git("--git-dir", str(self.git_dir), *args, text=text, env=env, **kwargs)
|
|
114
|
+
|
|
115
|
+
def _gw(self, work_dir, *args, env=None, **kwargs):
|
|
116
|
+
"""Run git command with --git-dir and --work-tree set."""
|
|
117
|
+
return _git(
|
|
118
|
+
"--git-dir",
|
|
119
|
+
str(self.git_dir),
|
|
120
|
+
"--work-tree",
|
|
121
|
+
str(work_dir),
|
|
122
|
+
*args,
|
|
123
|
+
env=env,
|
|
124
|
+
**kwargs,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def list_tags(self):
|
|
128
|
+
"""Return all tag names in the repo."""
|
|
129
|
+
result = self._g("tag", "-l")
|
|
130
|
+
raw = result.stdout.strip()
|
|
131
|
+
return raw.split("\n") if raw else []
|
|
132
|
+
|
|
133
|
+
def list_tags_sorted_by_date(self):
|
|
134
|
+
"""Return tag names sorted by their commit date (oldest first).
|
|
135
|
+
|
|
136
|
+
Uses creatordate which works for both annotated (taggerdate)
|
|
137
|
+
and lightweight (committerdate) tags.
|
|
138
|
+
"""
|
|
139
|
+
result = self._g(
|
|
140
|
+
"for-each-ref",
|
|
141
|
+
"--sort=creatordate",
|
|
142
|
+
"--format=%(refname:short)",
|
|
143
|
+
"refs/tags/",
|
|
144
|
+
)
|
|
145
|
+
raw = result.stdout.strip()
|
|
146
|
+
return raw.split("\n") if raw else []
|
|
147
|
+
|
|
148
|
+
def tag_commit_date(self, tag):
|
|
149
|
+
"""Return the ISO-8601 commit date for a tag."""
|
|
150
|
+
result = self._g("log", "-1", "--format=%cI", tag, check=False)
|
|
151
|
+
if result.returncode != 0:
|
|
152
|
+
return ""
|
|
153
|
+
return result.stdout.strip()
|
|
154
|
+
|
|
155
|
+
def resolve_commit(self, ref):
|
|
156
|
+
"""Return the 40-char commit SHA for a ref, tag, or commitish."""
|
|
157
|
+
if not ref:
|
|
158
|
+
return ""
|
|
159
|
+
result = self._g("rev-parse", "--verify", f"{ref}^{{commit}}", check=False)
|
|
160
|
+
if result.returncode == 0:
|
|
161
|
+
return result.stdout.strip()
|
|
162
|
+
return ""
|
|
163
|
+
|
|
164
|
+
def export_commit(self, commit_sha, dest):
|
|
165
|
+
"""Export a commit's file tree into *dest* (empty dir recommended)."""
|
|
166
|
+
result = self._g("archive", commit_sha, "--format=tar", text=False)
|
|
167
|
+
with tarfile.open(fileobj=io.BytesIO(result.stdout), mode="r|") as tar:
|
|
168
|
+
tar.extractall(path=str(dest))
|
|
169
|
+
|
|
170
|
+
def export_tag(self, tag, dest):
|
|
171
|
+
"""Export a tag's file tree into *dest* (empty dir recommended)."""
|
|
172
|
+
commit_sha = self.resolve_commit(tag) or tag
|
|
173
|
+
self.export_commit(commit_sha, dest)
|
|
174
|
+
|
|
175
|
+
def ensure_branch(self, branch):
|
|
176
|
+
"""Ensure HEAD points to *branch*, creating it if needed.
|
|
177
|
+
|
|
178
|
+
Priority: local branch → remote tracking → new branch.
|
|
179
|
+
"""
|
|
180
|
+
# Already exists locally
|
|
181
|
+
local = self._g("show-ref", "--verify", f"refs/heads/{branch}", check=False)
|
|
182
|
+
if local.returncode == 0:
|
|
183
|
+
self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# Exists on remote
|
|
187
|
+
remote = self._g(
|
|
188
|
+
"show-ref", "--verify", f"refs/remotes/origin/{branch}", check=False
|
|
189
|
+
)
|
|
190
|
+
if remote.returncode == 0:
|
|
191
|
+
self._g("branch", "--force", branch, f"origin/{branch}")
|
|
192
|
+
self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
# Brand new branch — just set HEAD, the ref is created on first commit
|
|
196
|
+
self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
|
|
197
|
+
|
|
198
|
+
def commit(self, work_dir, message, date=None, author_name=None, author_email=None):
|
|
199
|
+
"""Stage all files in *work_dir* and commit on current branch.
|
|
200
|
+
|
|
201
|
+
If *date* is provided (ISO-8601 string), sets both author and committer
|
|
202
|
+
dates so the commit appears at the correct chronological position.
|
|
203
|
+
|
|
204
|
+
If *author_name* and/or *author_email* are provided, they override the
|
|
205
|
+
git identity for this commit (both author and committer).
|
|
206
|
+
"""
|
|
207
|
+
self._gw(work_dir, "add", "-A")
|
|
208
|
+
env = dict(os.environ)
|
|
209
|
+
if date:
|
|
210
|
+
env.update({
|
|
211
|
+
"GIT_AUTHOR_DATE": date,
|
|
212
|
+
"GIT_COMMITTER_DATE": date,
|
|
213
|
+
})
|
|
214
|
+
if author_name:
|
|
215
|
+
env.update({
|
|
216
|
+
"GIT_AUTHOR_NAME": author_name,
|
|
217
|
+
"GIT_COMMITTER_NAME": author_name,
|
|
218
|
+
})
|
|
219
|
+
if author_email:
|
|
220
|
+
env.update({
|
|
221
|
+
"GIT_AUTHOR_EMAIL": author_email,
|
|
222
|
+
"GIT_COMMITTER_EMAIL": author_email,
|
|
223
|
+
})
|
|
224
|
+
if not date and not author_name and not author_email:
|
|
225
|
+
env = None
|
|
226
|
+
result = self._gw(
|
|
227
|
+
work_dir,
|
|
228
|
+
"commit",
|
|
229
|
+
"--no-verify",
|
|
230
|
+
"-m",
|
|
231
|
+
message,
|
|
232
|
+
check=False,
|
|
233
|
+
env=env,
|
|
234
|
+
)
|
|
235
|
+
# Exit code 1 from `git commit` means "nothing to commit" (defined contract)
|
|
236
|
+
if result.returncode not in (0, 1):
|
|
237
|
+
result.check_returncode()
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
def tag(self, name, message):
|
|
241
|
+
"""Create (or force-update) an annotated tag."""
|
|
242
|
+
self._g("tag", "-f", name, "-m", message)
|
|
243
|
+
|
|
244
|
+
def reset_worktree(self):
|
|
245
|
+
"""Populate the working tree to match HEAD. No-op for bare repos."""
|
|
246
|
+
if not self.is_bare:
|
|
247
|
+
_git("-C", str(self.git_dir.parent), "reset", "--hard", "HEAD")
|
|
248
|
+
|
|
249
|
+
def push(self, remote, *refs):
|
|
250
|
+
"""Push specific refs to remote."""
|
|
251
|
+
# Mirrors (cloned with --mirror) reject explicit refspecs by default.
|
|
252
|
+
# Temporarily disable mirror mode so we can push only the refs we need.
|
|
253
|
+
if self.is_bare:
|
|
254
|
+
self._g("config", "--local", "remote.origin.mirror", "false")
|
|
255
|
+
try:
|
|
256
|
+
self._g("push", remote, *refs)
|
|
257
|
+
finally:
|
|
258
|
+
self._g("config", "--local", "remote.origin.mirror", "true")
|
|
259
|
+
else:
|
|
260
|
+
self._g("push", remote, *refs)
|
|
261
|
+
|
|
262
|
+
def tag_exists(self, tag):
|
|
263
|
+
"""Check if a tag exists locally."""
|
|
264
|
+
result = self._g("show-ref", "--verify", f"refs/tags/{tag}", check=False)
|
|
265
|
+
return result.returncode == 0
|
|
266
|
+
|
|
267
|
+
def head_sha(self):
|
|
268
|
+
"""Return the SHA of HEAD."""
|
|
269
|
+
result = self._g("rev-parse", "HEAD")
|
|
270
|
+
return result.stdout.strip()
|
|
271
|
+
|
|
272
|
+
def is_ancestor(self, older_sha, newer_sha):
|
|
273
|
+
"""Return True if *older_sha* is a (strict) ancestor of *newer_sha*.
|
|
274
|
+
|
|
275
|
+
Uses ``git merge-base --is-ancestor`` which exits 0 when true and 1
|
|
276
|
+
when false. Returns False for any git error (e.g. unknown ref).
|
|
277
|
+
"""
|
|
278
|
+
result = self._g(
|
|
279
|
+
"merge-base", "--is-ancestor", older_sha, newer_sha, check=False
|
|
280
|
+
)
|
|
281
|
+
return result.returncode == 0
|
|
282
|
+
|
|
283
|
+
def resolve_default_branch_head(self, branch=None):
|
|
284
|
+
"""Return the commit SHA at the tip of the default (or given) branch.
|
|
285
|
+
|
|
286
|
+
Supports both bare mirrors (where refs usually live under ``refs/heads/``
|
|
287
|
+
and ``HEAD`` points to the default branch) and remote-tracking setups
|
|
288
|
+
(``refs/remotes/origin/``).
|
|
289
|
+
"""
|
|
290
|
+
if branch:
|
|
291
|
+
candidates = [
|
|
292
|
+
f"refs/heads/{branch}",
|
|
293
|
+
f"refs/remotes/origin/{branch}",
|
|
294
|
+
f"refs/remotes/{branch}",
|
|
295
|
+
branch,
|
|
296
|
+
]
|
|
297
|
+
for ref in candidates:
|
|
298
|
+
result = self._g("rev-parse", "--verify", ref, check=False)
|
|
299
|
+
if result.returncode == 0:
|
|
300
|
+
return result.stdout.strip()
|
|
301
|
+
logger.warning("Could not resolve branch '%s' in %s", branch, self.git_dir)
|
|
302
|
+
return ""
|
|
303
|
+
|
|
304
|
+
# Auto-detect default branch:
|
|
305
|
+
# 1. Try symbolic-ref HEAD (works on bare mirrors and non-bare working copies)
|
|
306
|
+
result = self._g("symbolic-ref", "--short", "HEAD", check=False)
|
|
307
|
+
if result.returncode == 0:
|
|
308
|
+
branch_name = result.stdout.strip()
|
|
309
|
+
for ref in (f"refs/heads/{branch_name}", branch_name, "HEAD"):
|
|
310
|
+
r = self._g("rev-parse", "--verify", ref, check=False)
|
|
311
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
312
|
+
return r.stdout.strip()
|
|
313
|
+
|
|
314
|
+
# 2. Try refs/remotes/origin/HEAD
|
|
315
|
+
result = self._g(
|
|
316
|
+
"symbolic-ref", "--short", "refs/remotes/origin/HEAD", check=False
|
|
317
|
+
)
|
|
318
|
+
if result.returncode == 0:
|
|
319
|
+
remote_branch = result.stdout.strip() # e.g. "origin/main"
|
|
320
|
+
ref = f"refs/remotes/{remote_branch}"
|
|
321
|
+
r = self._g("rev-parse", "--verify", ref, check=False)
|
|
322
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
323
|
+
return r.stdout.strip()
|
|
324
|
+
|
|
325
|
+
# 3. Fall back to common branch names in refs/heads/ or refs/remotes/origin/
|
|
326
|
+
for candidate in ("main", "master", "trunk", "development", "dev", "default"):
|
|
327
|
+
for ref in (f"refs/heads/{candidate}", f"refs/remotes/origin/{candidate}"):
|
|
328
|
+
r = self._g("rev-parse", "--verify", ref, check=False)
|
|
329
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
330
|
+
return r.stdout.strip()
|
|
331
|
+
|
|
332
|
+
# 4. Fall back to direct HEAD rev-parse
|
|
333
|
+
r = self._g("rev-parse", "--verify", "HEAD", check=False)
|
|
334
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
335
|
+
return r.stdout.strip()
|
|
336
|
+
|
|
337
|
+
logger.warning(
|
|
338
|
+
"Could not determine default branch for repository at %s",
|
|
339
|
+
self.git_dir,
|
|
340
|
+
)
|
|
341
|
+
return ""
|
|
342
|
+
|
gitacross/gitea.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Backwards-compatibility shim — re-exports GiteaClient from gitacross.providers.
|
|
2
|
+
|
|
3
|
+
New code should import from ``gitacross.providers.gitea`` or use
|
|
4
|
+
``gitacross.providers.get_api_client`` instead.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .providers.gitea import GiteaClient # noqa: F401
|
|
8
|
+
|
|
9
|
+
__all__ = ["GiteaClient"]
|
gitacross/github.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Backwards-compatibility shim — re-exports GitHubClient from gitacross.providers.
|
|
2
|
+
|
|
3
|
+
New code should import from ``gitacross.providers.github`` or use
|
|
4
|
+
``gitacross.providers.get_api_client`` instead.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .providers.github import GitHubClient # noqa: F401
|
|
8
|
+
|
|
9
|
+
__all__ = ["GitHubClient"]
|