gitacross 2.2.0__tar.gz → 2.3.0__tar.gz
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-2.2.0/src/gitacross.egg-info → gitacross-2.3.0}/PKG-INFO +8 -1
- {gitacross-2.2.0 → gitacross-2.3.0}/README.md +7 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/pyproject.toml +1 -1
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/git.py +113 -15
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/target.py +7 -2
- {gitacross-2.2.0 → gitacross-2.3.0/src/gitacross.egg-info}/PKG-INFO +8 -1
- {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_git.py +172 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_integration.py +221 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_source.py +30 -12
- {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_sync_api.py +23 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_target.py +94 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/LICENSE +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/setup.cfg +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/__init__.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/__main__.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/cli.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/config.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/linter/__init__.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/linter/fixer.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/linter/keys.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/linter/models.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/linter/validator.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/main.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/providers/__init__.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/providers/base.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/providers/gitea.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/providers/github.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/renderer.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/retry.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/source.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/state.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross.egg-info/SOURCES.txt +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross.egg-info/entry_points.txt +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross.egg-info/requires.txt +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross.egg-info/top_level.txt +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_config.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_linter.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_providers.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_renderer.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitacross
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines.
|
|
5
5
|
Author: Matthew Deik
|
|
6
6
|
License-Expression: MIT
|
|
@@ -157,6 +157,13 @@ A config file starts with a `projects` list — each entry is one mirror and nee
|
|
|
157
157
|
|
|
158
158
|
Tokens use `${VAR}` syntax — resolved from environment variables.
|
|
159
159
|
|
|
160
|
+
For local **targets**, the `path` does not have to exist yet: if the directory is
|
|
161
|
+
missing or is not already a git repository, GitAcross creates the directory and
|
|
162
|
+
runs `git init` there before committing. If a repository already exists at that
|
|
163
|
+
path it is opened as-is — existing git metadata is never re-initialised or
|
|
164
|
+
overwritten (bare repositories and broken `.git` markers are refused with an
|
|
165
|
+
error). Local **sources** must point at an existing git repository.
|
|
166
|
+
|
|
160
167
|
| Option | Description |
|
|
161
168
|
|---|---|
|
|
162
169
|
| [`enabled`](#enabled) | Disable a project without deleting it |
|
|
@@ -128,6 +128,13 @@ A config file starts with a `projects` list — each entry is one mirror and nee
|
|
|
128
128
|
|
|
129
129
|
Tokens use `${VAR}` syntax — resolved from environment variables.
|
|
130
130
|
|
|
131
|
+
For local **targets**, the `path` does not have to exist yet: if the directory is
|
|
132
|
+
missing or is not already a git repository, GitAcross creates the directory and
|
|
133
|
+
runs `git init` there before committing. If a repository already exists at that
|
|
134
|
+
path it is opened as-is — existing git metadata is never re-initialised or
|
|
135
|
+
overwritten (bare repositories and broken `.git` markers are refused with an
|
|
136
|
+
error). Local **sources** must point at an existing git repository.
|
|
137
|
+
|
|
131
138
|
| Option | Description |
|
|
132
139
|
|---|---|
|
|
133
140
|
| [`enabled`](#enabled) | Disable a project without deleting it |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitacross"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
8
8
|
description = "Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -37,9 +37,16 @@ def _log_git_stderr(command, returncode, stderr, level):
|
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def _git(*args, check=True, input_data=None, text=True, env=None):
|
|
41
|
-
cmd = ["git"]
|
|
42
|
-
|
|
40
|
+
def _git(*args, check=True, input_data=None, text=True, env=None, safe_dir=None):
|
|
41
|
+
cmd = ["git"]
|
|
42
|
+
if safe_dir:
|
|
43
|
+
# Git refuses to operate in a repository owned by another user
|
|
44
|
+
# ("dubious ownership"). The bare mirrors under the cache dir are
|
|
45
|
+
# created and managed by gitacross itself, so whitelist exactly that
|
|
46
|
+
# directory for this one invocation via -c — never written to a config.
|
|
47
|
+
cmd += ["-c", f"safe.directory={Path(safe_dir).resolve()}"]
|
|
48
|
+
cmd += [str(a) for a in args]
|
|
49
|
+
logger.debug("> git %s", _redact(" ".join(str(a) for a in cmd[1:])))
|
|
43
50
|
try:
|
|
44
51
|
result = subprocess.run(
|
|
45
52
|
cmd,
|
|
@@ -56,6 +63,12 @@ def _git(*args, check=True, input_data=None, text=True, env=None):
|
|
|
56
63
|
if isinstance(part, str):
|
|
57
64
|
e.cmd[i] = _redact(part)
|
|
58
65
|
raise
|
|
66
|
+
except FileNotFoundError as exc:
|
|
67
|
+
# subprocess can't find the `git` binary at all — give a clear error
|
|
68
|
+
# instead of the raw "[Errno 2] No such file or directory: 'git'".
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"git executable not found — install Git and make sure it is on PATH."
|
|
71
|
+
) from exc
|
|
59
72
|
if result.returncode != 0:
|
|
60
73
|
# check=False path — callers inspect the return code themselves
|
|
61
74
|
_log_git_stderr(args[0], result.returncode, result.stderr, level=logging.DEBUG)
|
|
@@ -87,34 +100,117 @@ class GitRepo:
|
|
|
87
100
|
path = Path(dest)
|
|
88
101
|
if path.exists():
|
|
89
102
|
# Update remote URL *before* fetching so token rotation takes effect
|
|
90
|
-
current = _git(
|
|
103
|
+
current = _git(
|
|
104
|
+
"-C", str(path), "remote", "get-url", "origin",
|
|
105
|
+
check=False, safe_dir=path,
|
|
106
|
+
)
|
|
91
107
|
if current.returncode == 0 and current.stdout.strip() != url:
|
|
92
|
-
_ = _git(
|
|
108
|
+
_ = _git(
|
|
109
|
+
"-C", str(path), "remote", "set-url", "origin", url,
|
|
110
|
+
safe_dir=path,
|
|
111
|
+
)
|
|
93
112
|
logger.info("Updated remote URL for mirror at %s", dest)
|
|
94
|
-
_ = _git(
|
|
113
|
+
_ = _git(
|
|
114
|
+
"-C", str(path), "fetch", "--tags", "--prune", "origin",
|
|
115
|
+
safe_dir=path,
|
|
116
|
+
)
|
|
95
117
|
logger.info("Updated mirror at %s", dest)
|
|
96
118
|
else:
|
|
97
119
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
-
_ = _git("clone", "--mirror", url, str(path))
|
|
120
|
+
_ = _git("clone", "--mirror", url, str(path), safe_dir=path)
|
|
99
121
|
logger.info("Cloned mirror from %s", _redact(url))
|
|
100
122
|
# Ensure author identity for automated commits
|
|
101
|
-
_ = _git("-C", str(path), "config", "user.name", "GitAcross")
|
|
102
|
-
_ = _git("-C", str(path), "config", "user.email", "sync@gitacross")
|
|
123
|
+
_ = _git("-C", str(path), "config", "user.name", "GitAcross", safe_dir=path)
|
|
124
|
+
_ = _git("-C", str(path), "config", "user.email", "sync@gitacross", safe_dir=path)
|
|
103
125
|
return cls(path, is_bare=True)
|
|
104
126
|
|
|
105
127
|
@classmethod
|
|
106
128
|
def local(cls, path):
|
|
107
|
-
"""Open an existing local git repository.
|
|
129
|
+
"""Open an existing local git repository.
|
|
130
|
+
|
|
131
|
+
*path* must be the root of its own git working tree. Git resolves a
|
|
132
|
+
path that merely lives *inside* a repository to the nearest enclosing
|
|
133
|
+
repository, so without this check a configured path that is not itself
|
|
134
|
+
a git repository would silently commit to and reset that enclosing
|
|
135
|
+
repo — e.g. the directory the tool is being run from — instead of the
|
|
136
|
+
configured location.
|
|
137
|
+
"""
|
|
108
138
|
p = Path(path)
|
|
109
|
-
result = _git("-C", str(p), "rev-parse", "--
|
|
110
|
-
|
|
111
|
-
if not git_dir.exists():
|
|
139
|
+
result = _git("-C", str(p), "rev-parse", "--show-toplevel", check=False)
|
|
140
|
+
if result.returncode != 0:
|
|
112
141
|
raise ValueError(f"Not a git repository: {path}")
|
|
113
|
-
|
|
142
|
+
root = Path(result.stdout.strip())
|
|
143
|
+
if Path(p).resolve() != root.resolve():
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"Not a git repository: {path} — it is inside the git repository "
|
|
146
|
+
+ f"'{root}'. A local endpoint path must point at a repository "
|
|
147
|
+
+ f"root; run 'git init' in {path} or point the path at {root}."
|
|
148
|
+
)
|
|
149
|
+
git_dir = _git("-C", str(root), "rev-parse", "--absolute-git-dir")
|
|
150
|
+
return cls(Path(git_dir.stdout.strip()), is_bare=False)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def ensure_local(cls, path):
|
|
154
|
+
"""Open a local git repository at *path*, creating it if necessary.
|
|
155
|
+
|
|
156
|
+
Unlike :meth:`local`, a missing directory or a plain directory that is
|
|
157
|
+
not yet a git repository is accepted: the directory is created
|
|
158
|
+
(including parents) and ``git init`` is run there. Used for local
|
|
159
|
+
*targets*, which receive commits. Local *sources* still go through
|
|
160
|
+
:meth:`local` so a misconfigured source path is reported instead of
|
|
161
|
+
silently yielding an empty repository.
|
|
162
|
+
|
|
163
|
+
An existing repository is opened as-is and never re-initialised. If
|
|
164
|
+
*path* already holds git metadata that cannot be used as a working tree
|
|
165
|
+
(a bare repository, a git-internal directory, or a broken ``.git``
|
|
166
|
+
marker), a :class:`ValueError` is raised instead of overwriting it.
|
|
167
|
+
"""
|
|
168
|
+
if not path or not str(path).strip():
|
|
169
|
+
raise ValueError(
|
|
170
|
+
"Local target path is empty — set 'path' to the directory where "
|
|
171
|
+
+ "the mirrored repository should live."
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
return cls.local(path)
|
|
175
|
+
except ValueError:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
# Only auto-initialise when the directory is genuinely not a git
|
|
179
|
+
# repository yet — never run `git init` over existing git metadata
|
|
180
|
+
# (re-initialising a bare repo pollutes it with a nested .git).
|
|
181
|
+
p = Path(path)
|
|
182
|
+
if p.exists() and not p.is_dir():
|
|
183
|
+
raise ValueError(f"Local target path is not a directory: {path}")
|
|
184
|
+
if (p / ".git").exists():
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"Not initialising {path}: it already contains a '.git' entry "
|
|
187
|
+
+ "that is not a usable working tree. Refusing to overwrite it — "
|
|
188
|
+
+ "fix or remove that repository, or point the local target at a "
|
|
189
|
+
+ "missing or empty directory."
|
|
190
|
+
)
|
|
191
|
+
if (p / "HEAD").is_file() and (p / "objects").is_dir():
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"Not initialising {path}: it is a bare git repository or git's "
|
|
194
|
+
+ "internal directory. Local targets need a working tree, and "
|
|
195
|
+
+ "existing repositories are never re-initialised."
|
|
196
|
+
)
|
|
197
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
_ = _git("init", str(p))
|
|
199
|
+
logger.info("Initialised git repository at %s", p)
|
|
200
|
+
return cls.local(path)
|
|
114
201
|
|
|
115
202
|
def _g(self, *args, text=True, env=None, **kwargs):
|
|
116
203
|
"""Run git command with --git-dir set."""
|
|
117
|
-
return _git(
|
|
204
|
+
return _git(
|
|
205
|
+
"--git-dir",
|
|
206
|
+
str(self.git_dir),
|
|
207
|
+
*args,
|
|
208
|
+
text=text,
|
|
209
|
+
env=env,
|
|
210
|
+
# Mirrors are gitacross-managed; whitelist them for the ownership check.
|
|
211
|
+
safe_dir=self.git_dir if self.is_bare else None,
|
|
212
|
+
**kwargs,
|
|
213
|
+
)
|
|
118
214
|
|
|
119
215
|
def _gw(self, work_dir, *args, env=None, **kwargs):
|
|
120
216
|
"""Run git command with --git-dir and --work-tree set."""
|
|
@@ -125,6 +221,8 @@ class GitRepo:
|
|
|
125
221
|
str(work_dir),
|
|
126
222
|
*args,
|
|
127
223
|
env=env,
|
|
224
|
+
# Mirrors are gitacross-managed; whitelist them for the ownership check.
|
|
225
|
+
safe_dir=self.git_dir if self.is_bare else None,
|
|
128
226
|
**kwargs,
|
|
129
227
|
)
|
|
130
228
|
|
|
@@ -120,10 +120,15 @@ class _RemoteTarget:
|
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
class _LocalTarget:
|
|
123
|
-
"""Target backed by a local git repo — no push, no API release.
|
|
123
|
+
"""Target backed by a local git repo — no push, no API release.
|
|
124
|
+
|
|
125
|
+
The repository at ``config.path`` is created on demand (directory plus
|
|
126
|
+
``git init``) if it does not exist yet, so a local target can point at a
|
|
127
|
+
brand-new backup/mirror location.
|
|
128
|
+
"""
|
|
124
129
|
|
|
125
130
|
def __init__(self, config, author=None):
|
|
126
|
-
self._git = GitRepo.
|
|
131
|
+
self._git = GitRepo.ensure_local(config.path)
|
|
127
132
|
self._author = author
|
|
128
133
|
|
|
129
134
|
def setup(self, branch):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitacross
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines.
|
|
5
5
|
Author: Matthew Deik
|
|
6
6
|
License-Expression: MIT
|
|
@@ -157,6 +157,13 @@ A config file starts with a `projects` list — each entry is one mirror and nee
|
|
|
157
157
|
|
|
158
158
|
Tokens use `${VAR}` syntax — resolved from environment variables.
|
|
159
159
|
|
|
160
|
+
For local **targets**, the `path` does not have to exist yet: if the directory is
|
|
161
|
+
missing or is not already a git repository, GitAcross creates the directory and
|
|
162
|
+
runs `git init` there before committing. If a repository already exists at that
|
|
163
|
+
path it is opened as-is — existing git metadata is never re-initialised or
|
|
164
|
+
overwritten (bare repositories and broken `.git` markers are refused with an
|
|
165
|
+
error). Local **sources** must point at an existing git repository.
|
|
166
|
+
|
|
160
167
|
| Option | Description |
|
|
161
168
|
|---|---|
|
|
162
169
|
| [`enabled`](#enabled) | Disable a project without deleting it |
|
|
@@ -155,6 +155,23 @@ def test_git_error_redaction_non_str_cmd():
|
|
|
155
155
|
pass
|
|
156
156
|
print(" ✓ git: _git redacts cmd parts on failure")
|
|
157
157
|
|
|
158
|
+
|
|
159
|
+
def test_git_missing_binary_clear_error():
|
|
160
|
+
"""_git raises a clear error when the git executable is not available."""
|
|
161
|
+
from gitacross.git import _git
|
|
162
|
+
|
|
163
|
+
with mock.patch(
|
|
164
|
+
"gitacross.git.subprocess.run",
|
|
165
|
+
side_effect=FileNotFoundError("[Errno 2] No such file or directory: 'git'"),
|
|
166
|
+
):
|
|
167
|
+
try:
|
|
168
|
+
_git("rev-parse", "--git-dir")
|
|
169
|
+
assert False, "should have raised"
|
|
170
|
+
except ValueError as e:
|
|
171
|
+
assert "git executable not found" in str(e)
|
|
172
|
+
assert "PATH" in str(e)
|
|
173
|
+
print(" ✓ git: missing git binary produces a clear error")
|
|
174
|
+
|
|
158
175
|
def test_git_ensure_mirror_same_url_skips_set_url():
|
|
159
176
|
"""Re-ensuring a mirror with an unchanged URL skips the remote update."""
|
|
160
177
|
from gitacross.git import GitRepo
|
|
@@ -181,6 +198,84 @@ def test_git_ensure_mirror_same_url_skips_set_url():
|
|
|
181
198
|
assert any("fetch" in args for args in commands)
|
|
182
199
|
print(" ✓ git: ensure_mirror with unchanged URL skips remote update")
|
|
183
200
|
|
|
201
|
+
|
|
202
|
+
def test_git_mirror_commands_whitelist_own_directory():
|
|
203
|
+
"""Mirror commands carry a process-scoped safe.directory for the mirror dir.
|
|
204
|
+
|
|
205
|
+
Git refuses to operate in repositories owned by another user ("dubious
|
|
206
|
+
ownership") — a mirror cache created by an earlier run under a different
|
|
207
|
+
uid (containers/CI/shared volumes) must still be usable. The whitelist is
|
|
208
|
+
scoped to the gitacross-managed mirror via -c, never written to a config.
|
|
209
|
+
"""
|
|
210
|
+
from gitacross.git import GitRepo
|
|
211
|
+
from gitacross.git import subprocess as git_subprocess
|
|
212
|
+
|
|
213
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
214
|
+
origin = Path(tmp) / "origin"
|
|
215
|
+
_ = _make_git_repo(origin)
|
|
216
|
+
_ = _make_file(origin, "f.txt")
|
|
217
|
+
_git_commit(origin, "c1")
|
|
218
|
+
mirror = Path(tmp) / "mirror.git"
|
|
219
|
+
commands = []
|
|
220
|
+
real_run = git_subprocess.run
|
|
221
|
+
|
|
222
|
+
def spy(cmd, *args, **kwargs):
|
|
223
|
+
commands.append(cmd)
|
|
224
|
+
return real_run(cmd, *args, **kwargs)
|
|
225
|
+
|
|
226
|
+
with mock.patch("gitacross.git.subprocess.run", side_effect=spy):
|
|
227
|
+
_ = GitRepo.ensure_mirror(str(origin), mirror)
|
|
228
|
+
_ = GitRepo.ensure_mirror(str(origin), mirror) # refresh path too
|
|
229
|
+
|
|
230
|
+
safe_flag = ["-c", f"safe.directory={mirror.resolve()}"]
|
|
231
|
+
assert commands, "expected git commands to be issued"
|
|
232
|
+
for cmd in commands:
|
|
233
|
+
# Every command run against the mirror dir is whitelisted for it
|
|
234
|
+
assert any(str(mirror) == str(a) for a in cmd), f"unrelated: {cmd}"
|
|
235
|
+
assert cmd[1:3] == safe_flag, f"missing safe.directory: {cmd}"
|
|
236
|
+
print(" ✓ git: mirror commands whitelist their own directory")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_git_mirror_instance_commands_whitelisted_but_not_local():
|
|
240
|
+
"""Bare mirror instance commands carry safe.directory; local repos do not."""
|
|
241
|
+
from gitacross.git import GitRepo
|
|
242
|
+
from gitacross.git import subprocess as git_subprocess
|
|
243
|
+
|
|
244
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
245
|
+
origin = Path(tmp) / "origin"
|
|
246
|
+
_ = _make_git_repo(origin)
|
|
247
|
+
_ = _make_file(origin, "f.txt")
|
|
248
|
+
_git_commit(origin, "c1")
|
|
249
|
+
_git_tag(origin, "v1.0")
|
|
250
|
+
mirror = Path(tmp) / "mirror.git"
|
|
251
|
+
|
|
252
|
+
gr = GitRepo.ensure_mirror(str(origin), mirror)
|
|
253
|
+
local = Path(tmp) / "local"
|
|
254
|
+
_ = _make_git_repo(local)
|
|
255
|
+
_ = _make_file(local, "f.txt")
|
|
256
|
+
_git_commit(local, "c1")
|
|
257
|
+
_git_tag(local, "v1.0")
|
|
258
|
+
local_repo = GitRepo.local(local)
|
|
259
|
+
|
|
260
|
+
commands = []
|
|
261
|
+
real_run = git_subprocess.run
|
|
262
|
+
|
|
263
|
+
def spy(cmd, *args, **kwargs):
|
|
264
|
+
commands.append(cmd)
|
|
265
|
+
return real_run(cmd, *args, **kwargs)
|
|
266
|
+
|
|
267
|
+
with mock.patch("gitacross.git.subprocess.run", side_effect=spy):
|
|
268
|
+
assert "v1.0" in gr.list_tags() # bare mirror -> whitelisted
|
|
269
|
+
assert "v1.0" in local_repo.list_tags() # local -> not whitelisted
|
|
270
|
+
|
|
271
|
+
mirror_cmd = commands[0]
|
|
272
|
+
assert mirror_cmd[1:3] == ["-c", f"safe.directory={mirror.resolve()}"]
|
|
273
|
+
local_cmd = commands[1]
|
|
274
|
+
# Local (user-configured) repos are deliberately NOT auto-whitelisted
|
|
275
|
+
assert local_cmd[1] == "--git-dir"
|
|
276
|
+
assert not any("safe.directory" in a for a in local_cmd)
|
|
277
|
+
print(" ✓ git: mirror commands whitelisted, local repos left strict")
|
|
278
|
+
|
|
184
279
|
def test_git_reset_worktree_bare_noop():
|
|
185
280
|
"""reset_worktree is a no-op for bare repos — HEAD must stay untouched."""
|
|
186
281
|
from gitacross.git import GitRepo
|
|
@@ -193,6 +288,83 @@ def test_git_reset_worktree_bare_noop():
|
|
|
193
288
|
assert (bare / "HEAD").read_text() == head_before
|
|
194
289
|
print(" ✓ git: reset_worktree is a no-op for bare repos")
|
|
195
290
|
|
|
291
|
+
|
|
292
|
+
def test_git_ensure_local_opens_existing_repo_without_reinit():
|
|
293
|
+
"""ensure_local opens an existing repo as-is — no re-initialisation."""
|
|
294
|
+
from gitacross.git import GitRepo
|
|
295
|
+
from gitacross.git import _git as real_git
|
|
296
|
+
|
|
297
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
298
|
+
repo = Path(tmp) / "repo"
|
|
299
|
+
_ = _make_git_repo(repo)
|
|
300
|
+
_ = _make_file(repo, "f.txt")
|
|
301
|
+
_git_commit(repo, "c1")
|
|
302
|
+
commands = []
|
|
303
|
+
|
|
304
|
+
def spy(*args, **kwargs):
|
|
305
|
+
commands.append(args)
|
|
306
|
+
return real_git(*args, **kwargs)
|
|
307
|
+
|
|
308
|
+
with mock.patch("gitacross.git._git", side_effect=spy):
|
|
309
|
+
git = GitRepo.ensure_local(repo)
|
|
310
|
+
|
|
311
|
+
assert git.head_sha()
|
|
312
|
+
# No init/re-init command was ever issued for an existing repo
|
|
313
|
+
assert not any(args and args[0] == "init" for args in commands)
|
|
314
|
+
print(" ✓ git: ensure_local opens an existing repo without re-initialising")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_git_ensure_local_refuses_bare_repo():
|
|
318
|
+
"""ensure_local must not run `git init` over an existing bare repo."""
|
|
319
|
+
from gitacross.git import GitRepo
|
|
320
|
+
|
|
321
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
322
|
+
bare = Path(tmp) / "bare.git"
|
|
323
|
+
subprocess.run(["git", "init", "--bare", "-q", str(bare)], check=True)
|
|
324
|
+
before = sorted(p.name for p in bare.iterdir())
|
|
325
|
+
try:
|
|
326
|
+
_ = GitRepo.ensure_local(bare)
|
|
327
|
+
assert False, "should have raised"
|
|
328
|
+
except ValueError as e:
|
|
329
|
+
assert "bare" in str(e) or "re-initialis" in str(e)
|
|
330
|
+
# The bare repo must be left exactly as it was (no nested .git, etc.)
|
|
331
|
+
assert sorted(p.name for p in bare.iterdir()) == before
|
|
332
|
+
assert not (bare / ".git").exists()
|
|
333
|
+
print(" ✓ git: ensure_local refuses to re-initialise a bare repo")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_git_ensure_local_refuses_broken_git_marker():
|
|
337
|
+
"""ensure_local refuses a '.git' entry it cannot use instead of overwriting it."""
|
|
338
|
+
from gitacross.git import GitRepo
|
|
339
|
+
|
|
340
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
341
|
+
broken = Path(tmp) / "broken"
|
|
342
|
+
broken.mkdir()
|
|
343
|
+
marker = broken / ".git"
|
|
344
|
+
marker.write_text("gitdir: /does/not/exist\n")
|
|
345
|
+
try:
|
|
346
|
+
_ = GitRepo.ensure_local(broken)
|
|
347
|
+
assert False, "should have raised"
|
|
348
|
+
except ValueError as e:
|
|
349
|
+
assert "'.git'" in str(e)
|
|
350
|
+
assert marker.read_text() == "gitdir: /does/not/exist\n" # untouched
|
|
351
|
+
print(" ✓ git: ensure_local refuses a broken .git marker")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_git_ensure_local_refuses_file_path():
|
|
355
|
+
"""ensure_local rejects a path that is an existing file, not a directory."""
|
|
356
|
+
from gitacross.git import GitRepo
|
|
357
|
+
|
|
358
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
359
|
+
a_file = Path(tmp) / "some-file.txt"
|
|
360
|
+
a_file.write_text("not a dir")
|
|
361
|
+
try:
|
|
362
|
+
_ = GitRepo.ensure_local(a_file)
|
|
363
|
+
assert False, "should have raised"
|
|
364
|
+
except ValueError as e:
|
|
365
|
+
assert "not a directory" in str(e)
|
|
366
|
+
print(" ✓ git: ensure_local rejects a file path")
|
|
367
|
+
|
|
196
368
|
def test_git_push_non_bare():
|
|
197
369
|
"""push from a non-bare repo pushes the branch to the remote."""
|
|
198
370
|
from gitacross.git import GitRepo
|
|
@@ -146,6 +146,227 @@ def test_e2e_local_to_local():
|
|
|
146
146
|
os.chdir(old_cwd)
|
|
147
147
|
|
|
148
148
|
|
|
149
|
+
def test_e2e_local_target_auto_init_inside_ancestor_repo():
|
|
150
|
+
"""Local target path is created as its own repo — never the enclosing repo.
|
|
151
|
+
|
|
152
|
+
Regression: a target path that is not itself a repo but sits inside another
|
|
153
|
+
repository used to resolve to the enclosing repo, so syncs committed to and
|
|
154
|
+
hard-reset the directory the tool runs from instead of the configured path.
|
|
155
|
+
"""
|
|
156
|
+
from gitacross.config import Config
|
|
157
|
+
from gitacross.main import sync_project
|
|
158
|
+
|
|
159
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
160
|
+
tmp = Path(tmp)
|
|
161
|
+
|
|
162
|
+
# ── Source repo: one tag ──
|
|
163
|
+
src = tmp / "source"
|
|
164
|
+
_ = _make_git_repo(src)
|
|
165
|
+
_ = _make_file(src, "README.md", "# Project")
|
|
166
|
+
_git_commit(src, "first")
|
|
167
|
+
_git_tag(src, "v1.0")
|
|
168
|
+
|
|
169
|
+
# ── Ancestor repo that must NOT receive the sync ──
|
|
170
|
+
ancestor = tmp / "ancestor"
|
|
171
|
+
_ = _make_git_repo(ancestor)
|
|
172
|
+
_ = _make_file(ancestor, "keep.txt", "untouched")
|
|
173
|
+
_git_commit(ancestor, "c1")
|
|
174
|
+
ancestor_head_before = subprocess.run(
|
|
175
|
+
["git", "-C", str(ancestor), "rev-parse", "HEAD"],
|
|
176
|
+
capture_output=True, check=True, text=True,
|
|
177
|
+
).stdout.strip()
|
|
178
|
+
|
|
179
|
+
# ── Target path inside the ancestor repo, not itself a repo ──
|
|
180
|
+
tgt = ancestor / "test-repo"
|
|
181
|
+
|
|
182
|
+
cfg = tmp / "config.yml"
|
|
183
|
+
_ = cfg.write_text(f"""
|
|
184
|
+
- name: e2e-auto-init
|
|
185
|
+
source:
|
|
186
|
+
type: local
|
|
187
|
+
path: {src}
|
|
188
|
+
tag_pattern: "v*"
|
|
189
|
+
target:
|
|
190
|
+
type: local
|
|
191
|
+
path: {tgt}
|
|
192
|
+
branch: main
|
|
193
|
+
""")
|
|
194
|
+
|
|
195
|
+
old_cwd = Path.cwd()
|
|
196
|
+
os.chdir(tmp)
|
|
197
|
+
try:
|
|
198
|
+
config = Config(str(cfg))
|
|
199
|
+
_ = sync_project(config.projects[0], ".", dry_run=False)
|
|
200
|
+
|
|
201
|
+
# Synced content lives in the auto-created repo at the configured path
|
|
202
|
+
assert (tgt / ".git").is_dir()
|
|
203
|
+
assert (tgt / "README.md").read_text() == "# Project"
|
|
204
|
+
tags = subprocess.run(
|
|
205
|
+
["git", "-C", str(tgt), "tag", "-l"],
|
|
206
|
+
capture_output=True, check=True, text=True,
|
|
207
|
+
).stdout.strip().split()
|
|
208
|
+
assert "v1.0" in tags
|
|
209
|
+
|
|
210
|
+
# The ancestor repo was not touched
|
|
211
|
+
assert (ancestor / "keep.txt").read_text() == "untouched"
|
|
212
|
+
assert not (ancestor / "README.md").exists()
|
|
213
|
+
ancestor_head_after = subprocess.run(
|
|
214
|
+
["git", "-C", str(ancestor), "rev-parse", "HEAD"],
|
|
215
|
+
capture_output=True, check=True, text=True,
|
|
216
|
+
).stdout.strip()
|
|
217
|
+
assert ancestor_head_after == ancestor_head_before
|
|
218
|
+
|
|
219
|
+
print(" ✓ e2e: local target auto-initialised inside an ancestor repo")
|
|
220
|
+
finally:
|
|
221
|
+
os.chdir(old_cwd)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_e2e_local_target_existing_repo_on_target_branch_appends():
|
|
225
|
+
"""An existing repo already on the target branch keeps its history: release
|
|
226
|
+
commits are appended on top and nothing is re-initialised or rewritten.
|
|
227
|
+
"""
|
|
228
|
+
from gitacross.config import Config
|
|
229
|
+
from gitacross.main import sync_project
|
|
230
|
+
|
|
231
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
232
|
+
tmp = Path(tmp)
|
|
233
|
+
|
|
234
|
+
# ── Source: two releases ──
|
|
235
|
+
src = tmp / "source"
|
|
236
|
+
_ = _make_git_repo(src)
|
|
237
|
+
for tag, content in (("v1.0", "# v1"), ("v2.0", "# v2")):
|
|
238
|
+
_ = _make_file(src, "README.md", content)
|
|
239
|
+
_git_commit(src, f"c-{tag}")
|
|
240
|
+
_git_tag(src, tag)
|
|
241
|
+
|
|
242
|
+
# ── Target: existing repo on main with its own history ──
|
|
243
|
+
tgt = tmp / "target"
|
|
244
|
+
subprocess.run(["git", "init", "-q", "-b", "main", str(tgt)], check=True)
|
|
245
|
+
subprocess.run(["git", "-C", str(tgt), "config", "user.email", "t@test"], check=True)
|
|
246
|
+
subprocess.run(["git", "-C", str(tgt), "config", "user.name", "Test"], check=True)
|
|
247
|
+
_ = _make_file(tgt, "notes.txt", "unrelated pre-existing work")
|
|
248
|
+
_git_commit(tgt, "pre-existing work on main")
|
|
249
|
+
pre = subprocess.run(
|
|
250
|
+
["git", "-C", str(tgt), "rev-parse", "HEAD"],
|
|
251
|
+
capture_output=True, check=True, text=True,
|
|
252
|
+
).stdout.strip()
|
|
253
|
+
|
|
254
|
+
cfg = tmp / "config.yml"
|
|
255
|
+
_ = cfg.write_text(f"""
|
|
256
|
+
- name: e2e-existing
|
|
257
|
+
source:
|
|
258
|
+
type: local
|
|
259
|
+
path: {src}
|
|
260
|
+
tag_pattern: "v*"
|
|
261
|
+
target:
|
|
262
|
+
type: local
|
|
263
|
+
path: {tgt}
|
|
264
|
+
branch: main
|
|
265
|
+
""")
|
|
266
|
+
config = Config(str(cfg))
|
|
267
|
+
_ = sync_project(config.projects[0], work_dir=tmp / "state", dry_run=False)
|
|
268
|
+
|
|
269
|
+
# History was appended to, not replaced: pre-existing commit is an ancestor
|
|
270
|
+
rc = subprocess.run(
|
|
271
|
+
["git", "-C", str(tgt), "merge-base", "--is-ancestor", pre, "main"],
|
|
272
|
+
capture_output=True, check=False,
|
|
273
|
+
)
|
|
274
|
+
assert rc.returncode == 0, "existing history should be an ancestor of main"
|
|
275
|
+
count = subprocess.run(
|
|
276
|
+
["git", "-C", str(tgt), "rev-list", "--count", "main"],
|
|
277
|
+
capture_output=True, check=True, text=True,
|
|
278
|
+
).stdout.strip()
|
|
279
|
+
assert count == "3" # pre-existing + v1.0 + v2.0
|
|
280
|
+
|
|
281
|
+
# Branch tip carries the latest release content
|
|
282
|
+
assert (tgt / "README.md").read_text() == "# v2"
|
|
283
|
+
tags = subprocess.run(
|
|
284
|
+
["git", "-C", str(tgt), "tag", "-l"],
|
|
285
|
+
capture_output=True, check=True, text=True,
|
|
286
|
+
).stdout.strip().split()
|
|
287
|
+
assert "v1.0" in tags and "v2.0" in tags
|
|
288
|
+
|
|
289
|
+
# Re-running against the same repo is a no-op (state-driven)
|
|
290
|
+
_ = sync_project(config.projects[0], work_dir=tmp / "state", dry_run=False)
|
|
291
|
+
count2 = subprocess.run(
|
|
292
|
+
["git", "-C", str(tgt), "rev-list", "--count", "main"],
|
|
293
|
+
capture_output=True, check=True, text=True,
|
|
294
|
+
).stdout.strip()
|
|
295
|
+
assert count2 == count
|
|
296
|
+
|
|
297
|
+
print(" ✓ e2e: existing repo on target branch gets releases appended")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_e2e_local_target_existing_repo_on_other_branch_untouched():
|
|
301
|
+
"""An existing repo whose history lives on another branch (e.g. master) is
|
|
302
|
+
left untouched: sync builds on its own branch and never touches the other.
|
|
303
|
+
"""
|
|
304
|
+
from gitacross.config import Config
|
|
305
|
+
from gitacross.main import sync_project
|
|
306
|
+
|
|
307
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
308
|
+
tmp = Path(tmp)
|
|
309
|
+
|
|
310
|
+
src = tmp / "source"
|
|
311
|
+
_ = _make_git_repo(src)
|
|
312
|
+
_ = _make_file(src, "README.md", "# v1")
|
|
313
|
+
_git_commit(src, "c1")
|
|
314
|
+
_git_tag(src, "v1.0")
|
|
315
|
+
|
|
316
|
+
tgt = tmp / "target"
|
|
317
|
+
subprocess.run(["git", "init", "-q", "-b", "master", str(tgt)], check=True)
|
|
318
|
+
subprocess.run(["git", "-C", str(tgt), "config", "user.email", "t@test"], check=True)
|
|
319
|
+
subprocess.run(["git", "-C", str(tgt), "config", "user.name", "Test"], check=True)
|
|
320
|
+
_ = _make_file(tgt, "notes.txt", "unrelated work on master")
|
|
321
|
+
_git_commit(tgt, "pre-existing work on master")
|
|
322
|
+
master_before = subprocess.run(
|
|
323
|
+
["git", "-C", str(tgt), "rev-parse", "master"],
|
|
324
|
+
capture_output=True, check=True, text=True,
|
|
325
|
+
).stdout.strip()
|
|
326
|
+
|
|
327
|
+
cfg = tmp / "config.yml"
|
|
328
|
+
_ = cfg.write_text(f"""
|
|
329
|
+
- name: e2e-other-branch
|
|
330
|
+
source:
|
|
331
|
+
type: local
|
|
332
|
+
path: {src}
|
|
333
|
+
tag_pattern: "v*"
|
|
334
|
+
target:
|
|
335
|
+
type: local
|
|
336
|
+
path: {tgt}
|
|
337
|
+
branch: main
|
|
338
|
+
""")
|
|
339
|
+
config = Config(str(cfg))
|
|
340
|
+
_ = sync_project(config.projects[0], work_dir=tmp / "state", dry_run=False)
|
|
341
|
+
|
|
342
|
+
# master (and its content) is untouched
|
|
343
|
+
master_after = subprocess.run(
|
|
344
|
+
["git", "-C", str(tgt), "rev-parse", "master"],
|
|
345
|
+
capture_output=True, check=True, text=True,
|
|
346
|
+
).stdout.strip()
|
|
347
|
+
assert master_after == master_before
|
|
348
|
+
notes = subprocess.run(
|
|
349
|
+
["git", "-C", str(tgt), "ls-tree", "-r", "--name-only", "master"],
|
|
350
|
+
capture_output=True, check=True, text=True,
|
|
351
|
+
).stdout.strip()
|
|
352
|
+
assert notes == "notes.txt"
|
|
353
|
+
|
|
354
|
+
# Sync landed on its own new branch with release content
|
|
355
|
+
head = subprocess.run(
|
|
356
|
+
["git", "-C", str(tgt), "symbolic-ref", "--short", "HEAD"],
|
|
357
|
+
capture_output=True, check=True, text=True,
|
|
358
|
+
).stdout.strip()
|
|
359
|
+
assert head == "main"
|
|
360
|
+
assert (tgt / "README.md").read_text() == "# v1"
|
|
361
|
+
tags = subprocess.run(
|
|
362
|
+
["git", "-C", str(tgt), "tag", "-l"],
|
|
363
|
+
capture_output=True, check=True, text=True,
|
|
364
|
+
).stdout.strip().split()
|
|
365
|
+
assert "v1.0" in tags
|
|
366
|
+
|
|
367
|
+
print(" ✓ e2e: existing repo on another branch is left untouched")
|
|
368
|
+
|
|
369
|
+
|
|
149
370
|
def test_clean_text():
|
|
150
371
|
from gitacross.main import _clean_text
|
|
151
372
|
|
|
@@ -484,21 +484,39 @@ def test_git_local_raises_on_non_repo():
|
|
|
484
484
|
try:
|
|
485
485
|
_ = GitRepo.local(not_a_repo)
|
|
486
486
|
assert False, "should have raised"
|
|
487
|
-
except
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
# When rev-parse resolves a git dir that does not exist → ValueError
|
|
491
|
-
with mock.patch(
|
|
492
|
-
"gitacross.git._git", return_value=mock.Mock(stdout="missing_git_dir")
|
|
493
|
-
):
|
|
494
|
-
try:
|
|
495
|
-
_ = GitRepo.local(not_a_repo)
|
|
496
|
-
assert False, "should have raised"
|
|
497
|
-
except ValueError as e:
|
|
498
|
-
assert "Not a git repository" in str(e)
|
|
487
|
+
except ValueError as e:
|
|
488
|
+
assert "Not a git repository" in str(e)
|
|
499
489
|
print(" ✓ git: GitRepo.local raises on a non-repo directory")
|
|
500
490
|
|
|
501
491
|
|
|
492
|
+
def test_git_local_rejects_subdir_of_another_repo():
|
|
493
|
+
"""GitRepo.local must not silently resolve to an enclosing repository.
|
|
494
|
+
|
|
495
|
+
Regression: git walks up to the nearest enclosing repository, so a path
|
|
496
|
+
that is not itself a repo but sits inside one used to resolve to the
|
|
497
|
+
enclosing repo — committing to and hard-resetting *its* working tree
|
|
498
|
+
(e.g. the directory the tool is run from) instead of the configured path.
|
|
499
|
+
"""
|
|
500
|
+
from gitacross.git import GitRepo
|
|
501
|
+
|
|
502
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
503
|
+
outer = Path(tmp) / "outer"
|
|
504
|
+
_ = _make_git_repo(outer)
|
|
505
|
+
_ = _make_file(outer, "f.txt")
|
|
506
|
+
_git_commit(outer, "c1")
|
|
507
|
+
|
|
508
|
+
subdir = outer / "not_a_repo_itself"
|
|
509
|
+
subdir.mkdir()
|
|
510
|
+
try:
|
|
511
|
+
_ = GitRepo.local(subdir)
|
|
512
|
+
assert False, "should have raised"
|
|
513
|
+
except ValueError as e:
|
|
514
|
+
msg = str(e)
|
|
515
|
+
assert "Not a git repository" in msg
|
|
516
|
+
assert str(outer) in msg # hint at the actual enclosing repo
|
|
517
|
+
print(" ✓ git: GitRepo.local rejects a subdirectory of another repo")
|
|
518
|
+
|
|
519
|
+
|
|
502
520
|
def test_git_resolve_commit_unknown_returns_empty():
|
|
503
521
|
"""resolve_commit returns '' for an unknown ref instead of raising."""
|
|
504
522
|
from gitacross.git import GitRepo
|
|
@@ -340,6 +340,29 @@ def test_run_returns_synced_false_on_failure():
|
|
|
340
340
|
print(" ✓ run: returns synced=False on failure and continues remaining projects")
|
|
341
341
|
|
|
342
342
|
|
|
343
|
+
def test_run_git_missing_reports_clear_error(monkeypatch):
|
|
344
|
+
"""When git is not installed, run() reports a clear, actionable error."""
|
|
345
|
+
import gitacross
|
|
346
|
+
|
|
347
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
348
|
+
src = Path(tmp) / "src_repo"
|
|
349
|
+
_ = _make_git_repo(src) # created while git is still on PATH
|
|
350
|
+
cfg = Path(tmp) / "config.yml"
|
|
351
|
+
_write_local_config(
|
|
352
|
+
cfg, [{"name": "proj-gitless", "src": str(src), "tgt": str(Path(tmp) / "tgt")}]
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
monkeypatch.setenv("PATH", "/nonexistent") # hide git from the run
|
|
356
|
+
results = gitacross.run(str(cfg), work_dir=Path(tmp) / "state")
|
|
357
|
+
|
|
358
|
+
assert results[0]["project"] == "proj-gitless"
|
|
359
|
+
assert results[0]["synced"] is False
|
|
360
|
+
assert results[0]["releases_synced"] == 0
|
|
361
|
+
assert results[0]["releases"] == []
|
|
362
|
+
assert "git executable not found" in results[0]["error"]
|
|
363
|
+
print(" ✓ run: missing git binary surfaces a clear error message")
|
|
364
|
+
|
|
365
|
+
|
|
343
366
|
def test_run_dry_run_passed_through():
|
|
344
367
|
import gitacross
|
|
345
368
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"""Tests for target endpoints and retry — split from the original single-file suite."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import subprocess
|
|
4
5
|
import tempfile
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from unittest import mock
|
|
7
8
|
|
|
8
9
|
from tests.conftest import (
|
|
10
|
+
_git_commit,
|
|
9
11
|
_make_file,
|
|
10
12
|
_make_git_repo,
|
|
11
13
|
)
|
|
@@ -52,6 +54,98 @@ def test_target_local():
|
|
|
52
54
|
print(" ✓ target: local")
|
|
53
55
|
|
|
54
56
|
|
|
57
|
+
def test_target_local_empty_path_raises():
|
|
58
|
+
"""A local target without a path must not default to the current directory."""
|
|
59
|
+
from gitacross.config import _EndpointConfig
|
|
60
|
+
from gitacross.target import create_target
|
|
61
|
+
|
|
62
|
+
cfg = _EndpointConfig({"type": "local", "path": ""}, is_source=False)
|
|
63
|
+
try:
|
|
64
|
+
_ = create_target(cfg, "cache")
|
|
65
|
+
assert False, "should have raised"
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
assert "path" in str(e).lower()
|
|
68
|
+
print(" ✓ target: local target with empty path is rejected")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_target_local_auto_init_missing_dir():
|
|
72
|
+
"""A local target whose path does not exist yet is created (mkdir + init)."""
|
|
73
|
+
from gitacross.config import _EndpointConfig
|
|
74
|
+
from gitacross.target import create_target
|
|
75
|
+
|
|
76
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
77
|
+
target_path = Path(tmp) / "nested" / "backup-repo" # does not exist
|
|
78
|
+
|
|
79
|
+
cfg = _EndpointConfig(
|
|
80
|
+
{"type": "local", "path": str(target_path), "branch": "main"},
|
|
81
|
+
is_source=False,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
tgt = create_target(cfg, tmp)
|
|
85
|
+
tgt.setup("main")
|
|
86
|
+
|
|
87
|
+
work = Path(tmp) / "work"
|
|
88
|
+
work.mkdir()
|
|
89
|
+
_ = _make_file(work, "artifact.txt", "hello")
|
|
90
|
+
_ = tgt.commit(work, "Release v1.0")
|
|
91
|
+
|
|
92
|
+
# Repo + files exist at the configured path, not elsewhere
|
|
93
|
+
assert (target_path / ".git").is_dir()
|
|
94
|
+
assert (target_path / "artifact.txt").read_text() == "hello"
|
|
95
|
+
assert tgt.head_sha()
|
|
96
|
+
print(" ✓ target: local auto-initialises a missing target path")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_target_local_auto_init_inside_ancestor_repo():
|
|
100
|
+
"""A target path inside another repo must get its own repo, not clobber the ancestor.
|
|
101
|
+
|
|
102
|
+
Regression: git resolves a non-repo path inside a repository to the
|
|
103
|
+
enclosing repo, which used to make local targets commit to and hard-reset
|
|
104
|
+
the repository the tool is run from instead of the configured path.
|
|
105
|
+
"""
|
|
106
|
+
from gitacross.config import _EndpointConfig
|
|
107
|
+
from gitacross.target import create_target
|
|
108
|
+
|
|
109
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
110
|
+
ancestor = Path(tmp) / "ancestor"
|
|
111
|
+
_ = _make_git_repo(ancestor)
|
|
112
|
+
_ = _make_file(ancestor, "keep.txt", "must survive")
|
|
113
|
+
_git_commit(ancestor, "c1")
|
|
114
|
+
ancestor_head_before = subprocess.run(
|
|
115
|
+
["git", "-C", str(ancestor), "rev-parse", "HEAD"],
|
|
116
|
+
capture_output=True, check=True, text=True,
|
|
117
|
+
).stdout.strip()
|
|
118
|
+
|
|
119
|
+
target_path = ancestor / "test-repo" # not a repo itself
|
|
120
|
+
|
|
121
|
+
cfg = _EndpointConfig(
|
|
122
|
+
{"type": "local", "path": str(target_path), "branch": "main"},
|
|
123
|
+
is_source=False,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
tgt = create_target(cfg, tmp)
|
|
127
|
+
tgt.setup("main")
|
|
128
|
+
|
|
129
|
+
work = Path(tmp) / "work"
|
|
130
|
+
work.mkdir()
|
|
131
|
+
_ = _make_file(work, "artifact.txt", "hello")
|
|
132
|
+
_ = tgt.commit(work, "Release v1.0")
|
|
133
|
+
|
|
134
|
+
# The configured path is its own repo with the committed files
|
|
135
|
+
assert (target_path / ".git").is_dir()
|
|
136
|
+
assert (target_path / "artifact.txt").read_text() == "hello"
|
|
137
|
+
|
|
138
|
+
# The ancestor repo is untouched
|
|
139
|
+
assert (ancestor / "keep.txt").read_text() == "must survive"
|
|
140
|
+
ancestor_head_after = subprocess.run(
|
|
141
|
+
["git", "-C", str(ancestor), "rev-parse", "HEAD"],
|
|
142
|
+
capture_output=True, check=True, text=True,
|
|
143
|
+
).stdout.strip()
|
|
144
|
+
assert ancestor_head_after == ancestor_head_before
|
|
145
|
+
assert not (ancestor / "artifact.txt").exists()
|
|
146
|
+
print(" ✓ target: local auto-init inside an ancestor repo is isolated")
|
|
147
|
+
|
|
148
|
+
|
|
55
149
|
def test_target_remote_push_commit_mode():
|
|
56
150
|
from gitacross.config import _EndpointConfig
|
|
57
151
|
from gitacross.target import create_target
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|