git2svn 0.5.0__tar.gz → 0.5.2__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. {git2svn-0.5.0 → git2svn-0.5.2}/.github/workflows/ci.yml +10 -5
  2. {git2svn-0.5.0/git2svn.egg-info → git2svn-0.5.2}/PKG-INFO +3 -2
  3. {git2svn-0.5.0 → git2svn-0.5.2}/README.md +1 -1
  4. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn/__init__.py +3 -3
  5. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn/_version.py +2 -2
  6. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn/cli.py +1 -1
  7. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn/core.py +1 -1
  8. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn/git.py +6 -6
  9. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn/patcher.py +23 -16
  10. git2svn-0.5.2/git2svn/py.typed +1 -0
  11. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn/state.py +4 -2
  12. {git2svn-0.5.0 → git2svn-0.5.2/git2svn.egg-info}/PKG-INFO +3 -2
  13. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn.egg-info/SOURCES.txt +1 -0
  14. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn.egg-info/requires.txt +1 -0
  15. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn.egg-info/scm_file_list.json +1 -0
  16. {git2svn-0.5.0 → git2svn-0.5.2}/pyproject.toml +18 -0
  17. {git2svn-0.5.0 → git2svn-0.5.2}/tests/test_e2e.py +148 -0
  18. {git2svn-0.5.0 → git2svn-0.5.2}/.githooks/pre-commit +0 -0
  19. {git2svn-0.5.0 → git2svn-0.5.2}/.github/dependabot.yml +0 -0
  20. {git2svn-0.5.0 → git2svn-0.5.2}/.github/workflows/release.yml +0 -0
  21. {git2svn-0.5.0 → git2svn-0.5.2}/.gitignore +0 -0
  22. {git2svn-0.5.0 → git2svn-0.5.2}/.pre-commit-config.yaml +0 -0
  23. {git2svn-0.5.0 → git2svn-0.5.2}/CONTRIBUTING.md +0 -0
  24. {git2svn-0.5.0 → git2svn-0.5.2}/LICENSE.md +0 -0
  25. {git2svn-0.5.0 → git2svn-0.5.2}/docs/arc42/README.md +0 -0
  26. {git2svn-0.5.0 → git2svn-0.5.2}/docs/arc42/adrs.md +0 -0
  27. {git2svn-0.5.0 → git2svn-0.5.2}/docs/arc42/concepts.md +0 -0
  28. {git2svn-0.5.0 → git2svn-0.5.2}/docs/req42.md +0 -0
  29. {git2svn-0.5.0 → git2svn-0.5.2}/docs/user-guide/README.md +0 -0
  30. {git2svn-0.5.0 → git2svn-0.5.2}/docs/user-guide/commands.md +0 -0
  31. {git2svn-0.5.0 → git2svn-0.5.2}/docs/user-guide/integration-workflow.md +0 -0
  32. {git2svn-0.5.0 → git2svn-0.5.2}/docs/user-guide/troubleshooting.md +0 -0
  33. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn/__main__.py +0 -0
  34. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn/eol.py +0 -0
  35. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn/svn.py +0 -0
  36. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn.egg-info/dependency_links.txt +0 -0
  37. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn.egg-info/entry_points.txt +0 -0
  38. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn.egg-info/top_level.txt +0 -0
  39. {git2svn-0.5.0 → git2svn-0.5.2}/git2svn.py +0 -0
  40. {git2svn-0.5.0 → git2svn-0.5.2}/setup.cfg +0 -0
  41. {git2svn-0.5.0 → git2svn-0.5.2}/tests/test_git2svn.py +0 -0
@@ -7,8 +7,8 @@ on:
7
7
  branches: [ "main" ]
8
8
 
9
9
  jobs:
10
- lint-and-format:
11
- name: Lint & Format (Ruff)
10
+ lint-format-typecheck:
11
+ name: Lint, Format & Type Check
12
12
  runs-on: ubuntu-latest
13
13
  steps:
14
14
  - name: Check out repository
@@ -19,8 +19,8 @@ jobs:
19
19
  with:
20
20
  python-version: "3.12"
21
21
 
22
- - name: Install Ruff
23
- run: pip install ruff
22
+ - name: Install Tooling
23
+ run: pip install ruff mypy
24
24
 
25
25
  - name: Run Ruff Lint Checks
26
26
  run: ruff check .
@@ -28,6 +28,9 @@ jobs:
28
28
  - name: Verify Formatting with Ruff
29
29
  run: ruff format --check .
30
30
 
31
+ - name: Type Check with MyPy
32
+ run: mypy git2svn
33
+
31
34
  test:
32
35
  name: Test on Python ${{ matrix.python-version }} (${{ matrix.os }})
33
36
  runs-on: ${{ matrix.os }}
@@ -65,7 +68,9 @@ jobs:
65
68
  echo "C:\Program Files\TortoiseSVN\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
66
69
 
67
70
  - name: Install Dependencies
68
- run: pip install -e .[dev]
71
+ run: |
72
+ python -m pip install --upgrade pip setuptools wheel
73
+ pip install --retries 5 -e .[dev]
69
74
 
70
75
  - name: Run Unit & Integration Tests (with Coverage)
71
76
  if: runner.os == 'Linux' && matrix.python-version == '3.12'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git2svn
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: CLI utility to synchronize changes from a local Git repository to a local SVN working copy
5
5
  Author: Simon
6
6
  License: Apache-2.0
@@ -26,6 +26,7 @@ Description-Content-Type: text/markdown
26
26
  License-File: LICENSE.md
27
27
  Provides-Extra: dev
28
28
  Requires-Dist: coverage>=7.0.0; extra == "dev"
29
+ Requires-Dist: mypy>=1.9.0; extra == "dev"
29
30
  Requires-Dist: ruff>=0.8.0; extra == "dev"
30
31
  Requires-Dist: setuptools-scm>=8.0; extra == "dev"
31
32
  Dynamic: license-file
@@ -35,7 +36,7 @@ Dynamic: license-file
35
36
  <p align="center">
36
37
  <a href="https://github.com/simon123h/git2svn/actions/workflows/ci.yml"><img src="https://github.com/simon123h/git2svn/actions/workflows/ci.yml/badge.svg?branch=main" alt="Build Status"></a>
37
38
  <a href="https://codecov.io/gh/simon123h/git2svn"><img src="https://codecov.io/gh/simon123h/git2svn/branch/main/graph/badge.svg" alt="Coverage"></a>
38
- <a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=blue" alt="PyPI Version"></a>
39
+ <a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=green" alt="PyPI Version"></a>
39
40
  <img src="https://img.shields.io/badge/Python-v3.11+-3776AB?logo=python&logoColor=white" alt="Python Version">
40
41
  <img src="https://img.shields.io/endpoint?url=https%3A%2F%2Ftokei.kojix2.net%2Fbadge%2Fgithub%2Fsimon123h%2Fgit2svn%2Flines" alt="Lines of Code">
41
42
  <a href="https://github.com/simon123h/git2svn/releases"><img src="https://badgen.net/github/release/simon123h/git2svn?color=orange" alt="Latest Release"></a>
@@ -3,7 +3,7 @@
3
3
  <p align="center">
4
4
  <a href="https://github.com/simon123h/git2svn/actions/workflows/ci.yml"><img src="https://github.com/simon123h/git2svn/actions/workflows/ci.yml/badge.svg?branch=main" alt="Build Status"></a>
5
5
  <a href="https://codecov.io/gh/simon123h/git2svn"><img src="https://codecov.io/gh/simon123h/git2svn/branch/main/graph/badge.svg" alt="Coverage"></a>
6
- <a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=blue" alt="PyPI Version"></a>
6
+ <a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=green" alt="PyPI Version"></a>
7
7
  <img src="https://img.shields.io/badge/Python-v3.11+-3776AB?logo=python&logoColor=white" alt="Python Version">
8
8
  <img src="https://img.shields.io/endpoint?url=https%3A%2F%2Ftokei.kojix2.net%2Fbadge%2Fgithub%2Fsimon123h%2Fgit2svn%2Flines" alt="Lines of Code">
9
9
  <a href="https://github.com/simon123h/git2svn/releases"><img src="https://badgen.net/github/release/simon123h/git2svn?color=orange" alt="Latest Release"></a>
@@ -20,7 +20,7 @@ from .state import (
20
20
  from .svn import SvnError, SvnLockError, SvnOutOfDateError, SvnWorkspace
21
21
 
22
22
  try:
23
- from ._version import __version__
23
+ from ._version import __version__ # type: ignore[import-not-found]
24
24
  except ImportError:
25
25
  __version__ = "0.0.0.dev0"
26
26
 
@@ -30,9 +30,9 @@ def get_version() -> str:
30
30
  try:
31
31
  from importlib.metadata import version
32
32
 
33
- return version("git2svn")
33
+ return str(version("git2svn"))
34
34
  except Exception:
35
- return __version__
35
+ return str(__version__)
36
36
 
37
37
 
38
38
  __all__ = [
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.5.0'
22
- __version_tuple__ = version_tuple = (0, 5, 0)
21
+ __version__ = version = '0.5.2'
22
+ __version_tuple__ = version_tuple = (0, 5, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -224,7 +224,7 @@ def build_parser() -> argparse.ArgumentParser:
224
224
  def handle_setup(git_repo: GitRepo, svn_dir_path: Optional[Path | str]) -> int:
225
225
  """Automate repository configuration, branch detection, and productivity aliases."""
226
226
  if not git_repo.is_valid_repo():
227
- print(f"Error: '{git_repo.root_dir}' is not a valid Git repository.", file=sys.stderr)
227
+ print(f"Error: '{git_repo.repo_dir}' is not a valid Git repository.", file=sys.stderr)
228
228
  return 1
229
229
 
230
230
  # 1. Resolve SVN working copy path
@@ -591,7 +591,7 @@ class Synchronizer:
591
591
  is_single, start_ref, end_ref = parse_ref_arguments(default_range)
592
592
  if is_single:
593
593
  commits = [start_ref] if self.git.ref_exists(start_ref) else []
594
- merges = []
594
+ merges: List[str] = []
595
595
  else:
596
596
  assert end_ref is not None
597
597
  if not self.git.ref_exists(start_ref):
@@ -218,15 +218,15 @@ class GitRepo:
218
218
  return res.stdout.strip()
219
219
  return None
220
220
 
221
- def get_diff(self, ref_spec: str) -> str:
222
- """Get unified diff for the given reference specification."""
223
- res = self.run_cmd(["diff", "--binary", ref_spec])
221
+ def get_diff(self, ref_spec: str) -> bytes:
222
+ """Get unified diff for the given reference specification as raw bytes."""
223
+ res = self.run_cmd_bytes(["diff", "--binary", ref_spec])
224
224
  return res.stdout
225
225
 
226
- def get_diff_root(self, commit_hash: str) -> str:
227
- """Get unified diff for a root commit."""
226
+ def get_diff_root(self, commit_hash: str) -> bytes:
227
+ """Get unified diff for a root commit as raw bytes."""
228
228
  empty_tree_hash = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
229
- res = self.run_cmd(["diff", "--binary", empty_tree_hash, commit_hash])
229
+ res = self.run_cmd_bytes(["diff", "--binary", empty_tree_hash, commit_hash])
230
230
  return res.stdout
231
231
 
232
232
  def get_name_status(self, ref_spec: str) -> List[FileChange]:
@@ -4,6 +4,7 @@ import logging
4
4
  import subprocess
5
5
  import sys
6
6
  from pathlib import Path
7
+ from typing import Union
7
8
 
8
9
  logger = logging.getLogger("git2svn")
9
10
 
@@ -16,43 +17,49 @@ class Patcher:
16
17
  self.git_bin = git_bin
17
18
  self.dry_run = dry_run
18
19
 
19
- def apply_diff(self, diff_content: str) -> None:
20
- """Apply unified diff text to target_dir using git apply."""
21
- if not diff_content.strip():
20
+ def apply_diff(self, diff_content: Union[str, bytes]) -> None:
21
+ """Apply unified diff text or bytes to target_dir using git apply."""
22
+ if isinstance(diff_content, str):
23
+ diff_bytes = diff_content.encode("utf-8")
24
+ else:
25
+ diff_bytes = diff_content
26
+
27
+ if not diff_bytes.strip():
22
28
  logger.info("Diff is empty. Nothing to patch.")
23
29
  return
24
30
 
25
31
  cmd = [self.git_bin, "apply", "--ignore-whitespace", "--unsafe-paths", "--reject"]
26
32
  if self.dry_run:
27
- print(f"[DRY-RUN] (in {self.target_dir}) {' '.join(cmd)} << EOF\n{diff_content.strip()[:200]}...\nEOF")
33
+ preview = diff_bytes.strip()[:200].decode("utf-8", errors="replace")
34
+ print(f"[DRY-RUN] (in {self.target_dir}) {' '.join(cmd)} << EOF\n{preview}...\nEOF")
28
35
  return
29
36
 
30
37
  logger.debug("Applying patch to %s using %s", self.target_dir, " ".join(cmd))
31
38
  proc = subprocess.run(
32
39
  cmd,
33
40
  cwd=self.target_dir,
34
- input=diff_content,
41
+ input=diff_bytes,
35
42
  capture_output=True,
36
- text=True,
37
- encoding="utf-8",
38
- errors="replace",
39
43
  )
40
44
 
45
+ stdout_str = proc.stdout.decode("utf-8", errors="replace") if proc.stdout else ""
46
+ stderr_str = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
47
+
41
48
  if proc.returncode != 0:
42
49
  logger.error(
43
- "git apply failed with code %d:\nSTDOUT:\n%s\nSTDERR:\n%s", proc.returncode, proc.stdout, proc.stderr
50
+ "git apply failed with code %d:\nSTDOUT:\n%s\nSTDERR:\n%s", proc.returncode, stdout_str, stderr_str
44
51
  )
45
52
  print(f"Error: git apply failed (exit code {proc.returncode}).", file=sys.stderr)
46
- if proc.stdout:
47
- print(proc.stdout, file=sys.stderr)
48
- if proc.stderr:
49
- print(proc.stderr, file=sys.stderr)
53
+ if stdout_str:
54
+ print(stdout_str, file=sys.stderr)
55
+ if stderr_str:
56
+ print(stderr_str, file=sys.stderr)
50
57
  print(
51
58
  "Tip: Check for .rej reject files in the SVN workspace or use 'git2svn stage --copy' to copy files directly.",
52
59
  file=sys.stderr,
53
60
  )
54
- raise subprocess.CalledProcessError(proc.returncode, cmd, proc.stdout, proc.stderr)
61
+ raise subprocess.CalledProcessError(proc.returncode, cmd, stdout_str, stderr_str)
55
62
  else:
56
- if proc.stdout or proc.stderr:
57
- msg = (proc.stdout + "\n" + proc.stderr).strip()
63
+ if stdout_str or stderr_str:
64
+ msg = (stdout_str + "\n" + stderr_str).strip()
58
65
  logger.info("Patch applied successfully:\n%s", msg)
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561. The git2svn package uses inline type annotations.
@@ -4,7 +4,7 @@ import json
4
4
  import logging
5
5
  import os
6
6
  from pathlib import Path
7
- from typing import Any, Dict, List, Optional
7
+ from typing import Any, Dict, List, Optional, cast
8
8
 
9
9
  logger = logging.getLogger("git2svn")
10
10
 
@@ -28,7 +28,9 @@ def load_replay_state(workspace_dir: Path) -> Optional[Dict[str, Any]]:
28
28
  path = get_replay_state_path(workspace_dir)
29
29
  if path.is_file():
30
30
  try:
31
- return json.loads(path.read_text(encoding="utf-8"))
31
+ val = json.loads(path.read_text(encoding="utf-8"))
32
+ if isinstance(val, dict):
33
+ return cast(Dict[str, Any], val)
32
34
  except Exception as e:
33
35
  logger.error("Failed to parse replay state file: %s", e)
34
36
  return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git2svn
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: CLI utility to synchronize changes from a local Git repository to a local SVN working copy
5
5
  Author: Simon
6
6
  License: Apache-2.0
@@ -26,6 +26,7 @@ Description-Content-Type: text/markdown
26
26
  License-File: LICENSE.md
27
27
  Provides-Extra: dev
28
28
  Requires-Dist: coverage>=7.0.0; extra == "dev"
29
+ Requires-Dist: mypy>=1.9.0; extra == "dev"
29
30
  Requires-Dist: ruff>=0.8.0; extra == "dev"
30
31
  Requires-Dist: setuptools-scm>=8.0; extra == "dev"
31
32
  Dynamic: license-file
@@ -35,7 +36,7 @@ Dynamic: license-file
35
36
  <p align="center">
36
37
  <a href="https://github.com/simon123h/git2svn/actions/workflows/ci.yml"><img src="https://github.com/simon123h/git2svn/actions/workflows/ci.yml/badge.svg?branch=main" alt="Build Status"></a>
37
38
  <a href="https://codecov.io/gh/simon123h/git2svn"><img src="https://codecov.io/gh/simon123h/git2svn/branch/main/graph/badge.svg" alt="Coverage"></a>
38
- <a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=blue" alt="PyPI Version"></a>
39
+ <a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=green" alt="PyPI Version"></a>
39
40
  <img src="https://img.shields.io/badge/Python-v3.11+-3776AB?logo=python&logoColor=white" alt="Python Version">
40
41
  <img src="https://img.shields.io/endpoint?url=https%3A%2F%2Ftokei.kojix2.net%2Fbadge%2Fgithub%2Fsimon123h%2Fgit2svn%2Flines" alt="Lines of Code">
41
42
  <a href="https://github.com/simon123h/git2svn/releases"><img src="https://badgen.net/github/release/simon123h/git2svn?color=orange" alt="Latest Release"></a>
@@ -25,6 +25,7 @@ git2svn/core.py
25
25
  git2svn/eol.py
26
26
  git2svn/git.py
27
27
  git2svn/patcher.py
28
+ git2svn/py.typed
28
29
  git2svn/state.py
29
30
  git2svn/svn.py
30
31
  git2svn.egg-info/PKG-INFO
@@ -1,5 +1,6 @@
1
1
 
2
2
  [dev]
3
3
  coverage>=7.0.0
4
+ mypy>=1.9.0
4
5
  ruff>=0.8.0
5
6
  setuptools-scm>=8.0
@@ -25,6 +25,7 @@
25
25
  "git2svn/eol.py",
26
26
  "git2svn/git.py",
27
27
  "git2svn/patcher.py",
28
+ "git2svn/py.typed",
28
29
  "git2svn/state.py",
29
30
  "git2svn/svn.py",
30
31
  "pyproject.toml",
@@ -37,6 +37,7 @@ Changelog = "https://github.com/simon123h/git2svn/releases"
37
37
  [project.optional-dependencies]
38
38
  dev = [
39
39
  "coverage>=7.0.0",
40
+ "mypy>=1.9.0",
40
41
  "ruff>=0.8.0",
41
42
  "setuptools-scm>=8.0",
42
43
  ]
@@ -85,3 +86,20 @@ exclude_lines = [
85
86
  "if __name__ == .__main__.:",
86
87
  ]
87
88
 
89
+ [tool.setuptools.package-data]
90
+ git2svn = ["py.typed"]
91
+
92
+ [tool.mypy]
93
+ python_version = "3.11"
94
+ strict = true
95
+ warn_unused_configs = true
96
+ disallow_untyped_defs = true
97
+ disallow_incomplete_defs = true
98
+ check_untyped_defs = true
99
+ no_implicit_optional = true
100
+ warn_redundant_casts = true
101
+ warn_unused_ignores = true
102
+ warn_return_any = true
103
+ show_error_codes = true
104
+
105
+
@@ -232,3 +232,151 @@ class TestGit2SvnE2E(unittest.TestCase):
232
232
  status = self._svn_status()
233
233
  self.assertIn("D obsolete.txt", status)
234
234
  self.assertIn("A brand_new.txt", status)
235
+
236
+ def test_e2e_replay_conflict_pause_and_continue(self):
237
+ """Test replay pausing on a patch conflict in a real SVN WC and resuming with --continue."""
238
+ # 1. Base commit in Git and SVN
239
+ f_git = self.git_dir / "conflict_target.txt"
240
+ f_git.write_text("line 1\nline 2\nline 3\n", encoding="utf-8")
241
+ base_hash = self._git_commit("base: conflict target")
242
+
243
+ (self.svn_wc_dir / "conflict_target.txt").write_text("line 1\nline 2\nline 3\n", encoding="utf-8")
244
+ subprocess.run([SVN_BIN, "add", "conflict_target.txt"], cwd=self.svn_wc_dir, check=True)
245
+ subprocess.run([SVN_BIN, "commit", "-m", "init conflict target"], cwd=self.svn_wc_dir, check=True)
246
+
247
+ # 2. Modify in SVN out-of-band to cause conflict
248
+ (self.svn_wc_dir / "conflict_target.txt").write_text("line 1 DIFFERENT\nline 2\nline 3\n", encoding="utf-8")
249
+ subprocess.run([SVN_BIN, "commit", "-m", "remote edit in svn"], cwd=self.svn_wc_dir, check=True)
250
+ subprocess.run([SVN_BIN, "update"], cwd=self.svn_wc_dir, check=True)
251
+
252
+ # 3. Git commit 1 (touches line 1 and 2, which conflicts)
253
+ f_git.write_text("line 1 GIT_EDIT\nline 2 GIT_EDIT\nline 3\n", encoding="utf-8")
254
+ self._git_commit("feat: conflicting git commit")
255
+
256
+ # 4. Git commit 2 (subsequent non-conflicting commit)
257
+ f_next = self.git_dir / "subsequent.txt"
258
+ f_next.write_text("subsequent content\n", encoding="utf-8")
259
+ c2_hash = self._git_commit("feat: subsequent commit")
260
+
261
+ # 5. Run replay range - should pause with non-zero exit code due to conflict
262
+ exit_code = cli_main(["replay", f"{base_hash}..{c2_hash}", "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
263
+ self.assertNotEqual(exit_code, 0)
264
+
265
+ # Verify .rej artifact exists
266
+ rej_file = self.svn_wc_dir / "conflict_target.txt.rej"
267
+ self.assertTrue(rej_file.exists())
268
+
269
+ # 6. Resolve conflict manually in SVN working copy and remove .rej artifact
270
+ (self.svn_wc_dir / "conflict_target.txt").write_text(
271
+ "line 1 RESOLVED\nline 2 GIT_EDIT\nline 3\n", encoding="utf-8"
272
+ )
273
+ rej_file.unlink()
274
+
275
+ # 7. Continue replay
276
+ cont_code = cli_main(["replay", "--continue", "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
277
+ self.assertEqual(cont_code, 0)
278
+
279
+ # Verify SVN working copy is clean and both commits are in SVN log
280
+ self.assertEqual(self._svn_status().strip(), "")
281
+ _, full_log = self._svn_log_messages()
282
+ self.assertIn("conflicting git commit", full_log)
283
+ self.assertIn("subsequent commit", full_log)
284
+
285
+ # Verify final files on disk in SVN
286
+ self.assertEqual(
287
+ (self.svn_wc_dir / "conflict_target.txt").read_text(encoding="utf-8"),
288
+ "line 1 RESOLVED\nline 2 GIT_EDIT\nline 3\n",
289
+ )
290
+ self.assertEqual((self.svn_wc_dir / "subsequent.txt").read_text(encoding="utf-8"), "subsequent content\n")
291
+
292
+ def test_e2e_file_renames_and_moves(self):
293
+ """Test replay correctly handles file renames and moves across directories in real SVN."""
294
+ # 1. Base commit: create src/old_name.txt
295
+ src_dir = self.git_dir / "src"
296
+ src_dir.mkdir()
297
+ orig_file = src_dir / "old_name.txt"
298
+ orig_file.write_text("print('hello rename')\n", encoding="utf-8")
299
+ base_hash = self._git_commit("init: original file")
300
+
301
+ # Replay base to SVN
302
+ code_base = cli_main(["replay", base_hash, "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
303
+ self.assertEqual(code_base, 0)
304
+
305
+ # 2. Git commit: rename src/old_name.txt -> src/nested/renamed.txt
306
+ nested_dir = src_dir / "nested"
307
+ nested_dir.mkdir()
308
+ subprocess.run([GIT_BIN, "mv", "src/old_name.txt", "src/nested/renamed.txt"], cwd=self.git_dir, check=True)
309
+ rename_hash = self._git_commit("refactor: move and rename file")
310
+
311
+ # 3. Replay rename commit to SVN
312
+ code_rename = cli_main(
313
+ ["replay", f"{base_hash}..{rename_hash}", "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)]
314
+ )
315
+ self.assertEqual(code_rename, 0)
316
+
317
+ # 4. Verify SVN state
318
+ self.assertFalse((self.svn_wc_dir / "src" / "old_name.txt").exists())
319
+ renamed_svn = self.svn_wc_dir / "src" / "nested" / "renamed.txt"
320
+ self.assertTrue(renamed_svn.is_file())
321
+ self.assertEqual(renamed_svn.read_text(encoding="utf-8"), "print('hello rename')\n")
322
+ self.assertEqual(self._svn_status().strip(), "")
323
+
324
+ def test_e2e_binary_files(self):
325
+ """Test replay correctly handles adding and modifying binary files with null bytes in real SVN."""
326
+ # 1. Root commit
327
+ (self.git_dir / "init.txt").write_text("init\n")
328
+ root_hash = self._git_commit("init: root commit")
329
+
330
+ # Replay root commit to SVN
331
+ code_root = cli_main(["replay", root_hash, "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
332
+ self.assertEqual(code_root, 0)
333
+
334
+ # 2. Add binary file
335
+ bin_git = self.git_dir / "image.bin"
336
+ initial_bytes = bytes([0x00, 0xFF, 0xFE, 0x01, 0x00, 0x42, 0xAA, 0x55] * 32)
337
+ bin_git.write_bytes(initial_bytes)
338
+ self._git_commit("feat: add binary image")
339
+
340
+ # 3. Modify binary file
341
+ modified_bytes = bytes([0x00, 0x00, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA] * 40)
342
+ bin_git.write_bytes(modified_bytes)
343
+ mod_hash = self._git_commit("fix: update binary bytes")
344
+
345
+ # 4. Replay range to SVN
346
+ code = cli_main(["replay", f"{root_hash}..{mod_hash}", "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
347
+ self.assertEqual(code, 0)
348
+
349
+ # 4. Verify SVN file content is bit-for-bit identical
350
+ bin_svn = self.svn_wc_dir / "image.bin"
351
+ self.assertTrue(bin_svn.is_file())
352
+ self.assertEqual(bin_svn.read_bytes(), modified_bytes)
353
+ self.assertEqual(self._svn_status().strip(), "")
354
+
355
+ def test_e2e_status_real_repo(self):
356
+ """Test git2svn status command execution against real Git and real SVN repositories."""
357
+ # Setup base commit in Git and SVN
358
+ f = self.git_dir / "status_test.txt"
359
+ f.write_text("status test\n", encoding="utf-8")
360
+ base_hash = self._git_commit("init: status test file")
361
+
362
+ cli_main(["replay", base_hash, "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
363
+
364
+ # Setup pending commit in Git
365
+ f.write_text("status test modified\n", encoding="utf-8")
366
+ pending_hash = self._git_commit("feat: pending feature for status")
367
+
368
+ # Configure defaultRange and svnDir in Git config
369
+ subprocess.run(
370
+ [GIT_BIN, "config", "git2svn.defaultRange", f"{base_hash}..{pending_hash}"],
371
+ cwd=self.git_dir,
372
+ check=True,
373
+ )
374
+ subprocess.run(
375
+ [GIT_BIN, "config", "git2svn.svnDir", str(self.svn_wc_dir)],
376
+ cwd=self.git_dir,
377
+ check=True,
378
+ )
379
+
380
+ # Run git2svn status
381
+ exit_code = cli_main(["status", "-g", str(self.git_dir)])
382
+ self.assertEqual(exit_code, 0)
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