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.
Files changed (41) hide show
  1. {gitacross-2.2.0/src/gitacross.egg-info → gitacross-2.3.0}/PKG-INFO +8 -1
  2. {gitacross-2.2.0 → gitacross-2.3.0}/README.md +7 -0
  3. {gitacross-2.2.0 → gitacross-2.3.0}/pyproject.toml +1 -1
  4. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/git.py +113 -15
  5. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/target.py +7 -2
  6. {gitacross-2.2.0 → gitacross-2.3.0/src/gitacross.egg-info}/PKG-INFO +8 -1
  7. {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_git.py +172 -0
  8. {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_integration.py +221 -0
  9. {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_source.py +30 -12
  10. {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_sync_api.py +23 -0
  11. {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_target.py +94 -0
  12. {gitacross-2.2.0 → gitacross-2.3.0}/LICENSE +0 -0
  13. {gitacross-2.2.0 → gitacross-2.3.0}/setup.cfg +0 -0
  14. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/__init__.py +0 -0
  15. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/__main__.py +0 -0
  16. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/cli.py +0 -0
  17. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/config.py +0 -0
  18. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/linter/__init__.py +0 -0
  19. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/linter/fixer.py +0 -0
  20. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/linter/keys.py +0 -0
  21. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/linter/models.py +0 -0
  22. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/linter/validator.py +0 -0
  23. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/main.py +0 -0
  24. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/providers/__init__.py +0 -0
  25. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/providers/base.py +0 -0
  26. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/providers/gitea.py +0 -0
  27. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/providers/github.py +0 -0
  28. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/renderer.py +0 -0
  29. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/retry.py +0 -0
  30. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/source.py +0 -0
  31. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross/state.py +0 -0
  32. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross.egg-info/SOURCES.txt +0 -0
  33. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
  34. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross.egg-info/entry_points.txt +0 -0
  35. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross.egg-info/requires.txt +0 -0
  36. {gitacross-2.2.0 → gitacross-2.3.0}/src/gitacross.egg-info/top_level.txt +0 -0
  37. {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_config.py +0 -0
  38. {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_linter.py +0 -0
  39. {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_providers.py +0 -0
  40. {gitacross-2.2.0 → gitacross-2.3.0}/tests/test_renderer.py +0 -0
  41. {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.2.0
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.2.0"
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"] + [str(a) for a in args]
42
- logger.debug("> git %s", _redact(" ".join(str(a) for a in args)))
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("-C", str(path), "remote", "get-url", "origin", check=False)
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("-C", str(path), "remote", "set-url", "origin", url)
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("-C", str(path), "fetch", "--tags", "--prune", "origin")
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", "--git-dir")
110
- git_dir = p / result.stdout.strip()
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
- return cls(git_dir, is_bare=False)
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("--git-dir", str(self.git_dir), *args, text=text, env=env, **kwargs)
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.local(config.path)
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.2.0
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 subprocess.CalledProcessError:
488
- pass # failing rev-parse surfaces as CalledProcessError
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