git2svn 0.4.1__tar.gz → 0.5.1__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 (40) hide show
  1. {git2svn-0.4.1 → git2svn-0.5.1}/.github/workflows/ci.yml +3 -1
  2. {git2svn-0.4.1/git2svn.egg-info → git2svn-0.5.1}/PKG-INFO +2 -2
  3. {git2svn-0.4.1 → git2svn-0.5.1}/README.md +1 -1
  4. {git2svn-0.4.1 → git2svn-0.5.1}/docs/arc42/adrs.md +12 -0
  5. {git2svn-0.4.1 → git2svn-0.5.1}/docs/user-guide/commands.md +22 -1
  6. {git2svn-0.4.1 → git2svn-0.5.1}/docs/user-guide/integration-workflow.md +6 -0
  7. {git2svn-0.4.1 → git2svn-0.5.1}/docs/user-guide/troubleshooting.md +30 -0
  8. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/__init__.py +4 -1
  9. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/_version.py +2 -2
  10. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/cli.py +25 -3
  11. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/core.py +150 -2
  12. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/git.py +23 -6
  13. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/patcher.py +23 -16
  14. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/svn.py +79 -10
  15. {git2svn-0.4.1 → git2svn-0.5.1/git2svn.egg-info}/PKG-INFO +2 -2
  16. {git2svn-0.4.1 → git2svn-0.5.1}/tests/test_e2e.py +148 -0
  17. {git2svn-0.4.1 → git2svn-0.5.1}/tests/test_git2svn.py +223 -0
  18. {git2svn-0.4.1 → git2svn-0.5.1}/.githooks/pre-commit +0 -0
  19. {git2svn-0.4.1 → git2svn-0.5.1}/.github/dependabot.yml +0 -0
  20. {git2svn-0.4.1 → git2svn-0.5.1}/.github/workflows/release.yml +0 -0
  21. {git2svn-0.4.1 → git2svn-0.5.1}/.gitignore +0 -0
  22. {git2svn-0.4.1 → git2svn-0.5.1}/.pre-commit-config.yaml +0 -0
  23. {git2svn-0.4.1 → git2svn-0.5.1}/CONTRIBUTING.md +0 -0
  24. {git2svn-0.4.1 → git2svn-0.5.1}/LICENSE.md +0 -0
  25. {git2svn-0.4.1 → git2svn-0.5.1}/docs/arc42/README.md +0 -0
  26. {git2svn-0.4.1 → git2svn-0.5.1}/docs/arc42/concepts.md +0 -0
  27. {git2svn-0.4.1 → git2svn-0.5.1}/docs/req42.md +0 -0
  28. {git2svn-0.4.1 → git2svn-0.5.1}/docs/user-guide/README.md +0 -0
  29. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/__main__.py +0 -0
  30. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/eol.py +0 -0
  31. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/state.py +0 -0
  32. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/SOURCES.txt +0 -0
  33. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/dependency_links.txt +0 -0
  34. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/entry_points.txt +0 -0
  35. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/requires.txt +0 -0
  36. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/scm_file_list.json +0 -0
  37. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/top_level.txt +0 -0
  38. {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.py +0 -0
  39. {git2svn-0.4.1 → git2svn-0.5.1}/pyproject.toml +0 -0
  40. {git2svn-0.4.1 → git2svn-0.5.1}/setup.cfg +0 -0
@@ -65,7 +65,9 @@ jobs:
65
65
  echo "C:\Program Files\TortoiseSVN\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
66
66
 
67
67
  - name: Install Dependencies
68
- run: pip install -e .[dev]
68
+ run: |
69
+ python -m pip install --upgrade pip setuptools wheel
70
+ pip install --retries 5 -e .[dev]
69
71
 
70
72
  - name: Run Unit & Integration Tests (with Coverage)
71
73
  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.4.1
3
+ Version: 0.5.1
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
@@ -35,7 +35,7 @@ Dynamic: license-file
35
35
  <p align="center">
36
36
  <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
37
  <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>
38
+ <a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=green" alt="PyPI Version"></a>
39
39
  <img src="https://img.shields.io/badge/Python-v3.11+-3776AB?logo=python&logoColor=white" alt="Python Version">
40
40
  <img src="https://img.shields.io/endpoint?url=https%3A%2F%2Ftokei.kojix2.net%2Fbadge%2Fgithub%2Fsimon123h%2Fgit2svn%2Flines" alt="Lines of Code">
41
41
  <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>
@@ -73,3 +73,15 @@ This document contains the Architecture Decision Records (ADRs) for `git2svn`.
73
73
  * **Context:** Subversion's mixed-revision working copy design leaves the local root revision behind `HEAD` after commits, hiding new revisions from `svn log` until `svn update` is run.
74
74
  * **Decision:** Provide an opt-in `-u` / `--update` flag and `git2svn.autoUpdate` setting to trigger `svn update` upon successful replay completion.
75
75
  * **Consequences:** Keeps local working copy base at `HEAD` while allowing users on massive remote SVN repositories to avoid extra network latency.
76
+
77
+ ---
78
+
79
+ ### ADR-11: No Author/Metadata Preservation or Date Spoofing
80
+ * **Context:** In Git, commits carry distinct author and committer names, emails, and historical timestamps. Subversion tracks revision properties (`svn:author`, `svn:date`) which are either immutable by default or require custom server-side hook scripts (`pre-revprop-change`) that are typically locked down in enterprise SVN deployments. Modifying revision properties or spoofing commit timestamps/authors can create security, audit, and traceability concerns, in addition to introducing server-dependent replay failures.
81
+ * **Decision:** Explicitly reject author spoofing, date manipulation, or artificial metadata rewriting in `git2svn`. All replayed commits are committed using the active SVN working copy user credentials and the current server timestamp. Original Git commit messages are preserved verbatim, but author identity and commit time in SVN remain authoritative to the person running the tool at the time of execution.
82
+ * **Consequences:**
83
+ - Guarantees compatibility with all SVN servers out of the box without requiring `pre-revprop-change` server hook privileges.
84
+ - Maintains strict organizational accountability: the committer who pushes the changes to SVN is always the verified SVN user.
85
+ - Avoids temporal inconsistencies in SVN repositories where revisions must strictly increase in time.
86
+ - Keeps the codebase lightweight and free of brittle revision-property manipulation logic.
87
+
@@ -153,10 +153,31 @@ git2svn setup /path/to/svn
153
153
  5. **Configures Safe Productivity Aliases:**
154
154
  - `git config alias.svn-push`: Replays trunk to SVN, fetches SVN mirror, and safely resets trunk *only* if `trunk` matches `svn-mirror/trunk` (guarded by `git diff --quiet`).
155
155
  - `git config alias.svn-pull`: Fetches SVN mirror, fast-forwards trunk if clean, and automatically rebases local commits if unpushed work exists on trunk.
156
+ - `git config alias.svn-status`: Inspects synchronization health, pending commits, and workspace state via `git2svn status`.
156
157
 
157
158
  ---
158
159
 
159
- ## 5. Structural Staging Mechanics
160
+ ## 5. Command: `status`
161
+
162
+ Inspects synchronization health, pending commits in `git2svn.defaultRange`, and working tree states across both Git and Subversion.
163
+
164
+ ```bash
165
+ git2svn status
166
+ ```
167
+
168
+ ### What `status` reports:
169
+ - **Git Workspace:** Active branch, latest commit hash & subject, and working tree cleanliness.
170
+ - **SVN Working Copy:** Path, target SVN URL, base revision, cleanliness, and lock status.
171
+ - **Synchronization Queue:** Configured `git2svn.defaultRange`, pending commit count & titles, and linear history validation (warns if merge commits exist).
172
+ - **In-Progress Replay:** State of paused replays (if a conflict occurred), remaining commits, active `.rej` conflict files, and actionable recovery commands (`--continue`, `--abort`, `--skip`).
173
+
174
+ ### Exit Codes:
175
+ - `0`: Workspace is clean, unlocked, and synchronization queue is valid.
176
+ - `1`: SVN working copy is locked/dirty, replay is paused due to conflict, or merge commits violate linear history.
177
+
178
+ ---
179
+
180
+ ## 6. Structural Staging Mechanics
160
181
 
161
182
  During patch application or file copying, `git2svn` maps Git status codes (`git diff --name-status`) to the corresponding Subversion commands:
162
183
 
@@ -114,6 +114,9 @@ git config alias.svn-pull "!f() { \
114
114
  git rebase svn-mirror/trunk; \
115
115
  fi; \
116
116
  }; f"
117
+
118
+ # 3. git svn-status: Inspects sync health, pending commits, and working tree states
119
+ git config alias.svn-status "!git2svn status"
117
120
  ```
118
121
 
119
122
  ### Safety Features of These Aliases:
@@ -124,6 +127,9 @@ git config alias.svn-pull "!f() { \
124
127
  With these aliases configured alongside `git config git2svn.defaultRange "svn-mirror/trunk..trunk"`:
125
128
 
126
129
  ```bash
130
+ # Check synchronization health, tree states, and pending commits:
131
+ git svn-status
132
+
127
133
  # Pull latest SVN state into local Git trunk (fast-forward or rebase):
128
134
  git svn-pull
129
135
 
@@ -53,3 +53,33 @@ git2svn replay -u <range>
53
53
  # Or configure globally:
54
54
  git config git2svn.autoUpdate true
55
55
  ```
56
+
57
+ ---
58
+
59
+ ### Q: Why does `git2svn` fail with "Subversion working copy is locked" (`E155004`)?
60
+ If Subversion was interrupted mid-operation (e.g. an aborted command, an IDE indexing run, or a background mirror sync), Subversion places lock files in `.svn` to prevent database corruption.
61
+
62
+ To resolve:
63
+ 1. Run:
64
+ ```bash
65
+ svn cleanup /path/to/svn
66
+ ```
67
+ 2. If background sync scripts or IDE indexing processes are actively touching the directory, wait for them to finish.
68
+ 3. Re-run your `git2svn` command.
69
+
70
+ ---
71
+
72
+ ### Q: Why does `git2svn` fail with "Item is out of date" or collision (`E155015` / `E160024`)?
73
+ This happens when another developer or automated process committed new revisions to Subversion upstream while your local working copy was still at an older revision. Subversion rejects committing against stale base revisions.
74
+
75
+ To resolve:
76
+ 1. Update your SVN working copy to bring it up to HEAD:
77
+ ```bash
78
+ svn update /path/to/svn
79
+ ```
80
+ 2. Check `svn status` to verify there are no tree or content conflicts.
81
+ 3. If an in-progress replay was paused, continue it with:
82
+ ```bash
83
+ git2svn replay --continue
84
+ ```
85
+
@@ -17,7 +17,7 @@ from .state import (
17
17
  load_replay_state,
18
18
  save_replay_state,
19
19
  )
20
- from .svn import SvnWorkspace
20
+ from .svn import SvnError, SvnLockError, SvnOutOfDateError, SvnWorkspace
21
21
 
22
22
  try:
23
23
  from ._version import __version__
@@ -39,6 +39,9 @@ __all__ = [
39
39
  "Synchronizer",
40
40
  "GitRepo",
41
41
  "SvnWorkspace",
42
+ "SvnError",
43
+ "SvnLockError",
44
+ "SvnOutOfDateError",
42
45
  "Patcher",
43
46
  "FileChange",
44
47
  "parse_ref_arguments",
@@ -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.4.1'
22
- __version_tuple__ = version_tuple = (0, 4, 1)
21
+ __version__ = version = '0.5.1'
22
+ __version_tuple__ = version_tuple = (0, 5, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -12,7 +12,7 @@ from .core import Synchronizer
12
12
  from .git import GitRepo
13
13
  from .patcher import Patcher
14
14
  from .state import load_replay_state
15
- from .svn import SvnWorkspace
15
+ from .svn import SvnError, SvnLockError, SvnOutOfDateError, SvnWorkspace
16
16
 
17
17
  logger = logging.getLogger("git2svn")
18
18
 
@@ -195,7 +195,7 @@ def build_parser() -> argparse.ArgumentParser:
195
195
  "Configure repository settings for git2svn on a fresh clone.\n"
196
196
  "Automatically detects local trunk and remote SVN mirror tracking branches,\n"
197
197
  "sets git2svn.svnDir, git2svn.defaultRange, git2svn.autoUpdate, pull.ff only,\n"
198
- "and configures git alias.svn-push and alias.svn-pull."
198
+ "and configures git alias.svn-push, alias.svn-pull, and alias.svn-status."
199
199
  ),
200
200
  )
201
201
  parser_setup.add_argument(
@@ -207,6 +207,17 @@ def build_parser() -> argparse.ArgumentParser:
207
207
  help="Path to SVN working copy (optional if already configured or set via --svn-dir)",
208
208
  )
209
209
 
210
+ # status
211
+ subparsers.add_parser(
212
+ "status",
213
+ parents=[common_parser],
214
+ help="Inspect synchronization health, pending commits, and workspace state",
215
+ description=(
216
+ "Display current state of Git repository, SVN working copy, in-progress replay,\n"
217
+ "and pending commits in the configured defaultRange."
218
+ ),
219
+ )
220
+
210
221
  return parser
211
222
 
212
223
 
@@ -315,9 +326,11 @@ def handle_setup(git_repo: GitRepo, svn_dir_path: Optional[Path | str]) -> int:
315
326
  f"echo '[git svn-pull] Fast-forward not possible (local commits on {trunk_name}). Rebasing onto {mirror_branch}...'; "
316
327
  f"git rebase {mirror_branch}; fi; }}; f"
317
328
  )
329
+ status_script = "!git2svn status"
318
330
 
319
331
  git_repo.set_config("alias.svn-push", push_script)
320
332
  git_repo.set_config("alias.svn-pull", pull_script)
333
+ git_repo.set_config("alias.svn-status", status_script)
321
334
 
322
335
  print("Successfully configured git2svn:")
323
336
  print(f" git2svn.svnDir = {svn_dir_str}")
@@ -328,6 +341,7 @@ def handle_setup(git_repo: GitRepo, svn_dir_path: Optional[Path | str]) -> int:
328
341
  print(" pull.ff = only")
329
342
  print(f" alias.svn-push = {push_script}")
330
343
  print(f" alias.svn-pull = {pull_script}")
344
+ print(f" alias.svn-status = {status_script}")
331
345
  return 0
332
346
 
333
347
 
@@ -425,7 +439,7 @@ def main(argv: Optional[List[str]] = None) -> int:
425
439
  sync_mgr = Synchronizer(git_repo, svn_workspace, patcher, dry_run=dry_run, auto_update=auto_update)
426
440
 
427
441
  # Fallback to git2svn.defaultRange if ref1 is omitted
428
- ref1 = args.ref1
442
+ ref1 = getattr(args, "ref1", None)
429
443
  ref2 = getattr(args, "ref2", None)
430
444
  if not ref1 and args.command in ("stage", "replay"):
431
445
  # Replay actions (--continue, --abort, --skip) do not need ref1
@@ -470,8 +484,16 @@ def main(argv: Optional[List[str]] = None) -> int:
470
484
  )
471
485
  return 1
472
486
  sync_mgr.replay(ref1, ref2)
487
+ elif args.command == "status":
488
+ return sync_mgr.status()
473
489
  else:
474
490
  return 1
491
+ except (SvnLockError, SvnOutOfDateError) as e:
492
+ print(f"\n[SVN Error] {e}", file=sys.stderr)
493
+ return e.returncode
494
+ except SvnError as e:
495
+ print(f"\n[SVN Error] {e}", file=sys.stderr)
496
+ return e.returncode
475
497
  except subprocess.CalledProcessError as e:
476
498
  logger.error("Process failed with returncode %s", e.returncode)
477
499
  return e.returncode
@@ -17,7 +17,7 @@ from .state import (
17
17
  load_replay_state,
18
18
  save_replay_state,
19
19
  )
20
- from .svn import SvnWorkspace
20
+ from .svn import SvnError, SvnWorkspace
21
21
 
22
22
  logger = logging.getLogger("git2svn")
23
23
 
@@ -261,13 +261,23 @@ class Synchronizer:
261
261
  )
262
262
 
263
263
  # 2. Commit the resolved changes for the interrupted commit
264
+ pad_width = len(str(total))
265
+ first_line = current_msg.splitlines()[0] if current_msg else ""
266
+ progress_prefix = f"[{completed + 1:>{pad_width}}/{total}] Resolving {current_commit[:8]}: {first_line}"
267
+
264
268
  if not self.svn.is_clean():
265
269
  logger.info("Committing resolved commit %s to SVN...", current_commit)
270
+ sys.stdout.write(f"{progress_prefix}... ")
271
+ sys.stdout.flush()
266
272
  self.svn.commit(current_msg)
273
+ sys.stdout.write("OK\n")
274
+ sys.stdout.flush()
267
275
  else:
268
276
  logger.info(
269
277
  "No changes in SVN workspace to commit for %s (commit resolved as empty or skipped).", current_commit
270
278
  )
279
+ sys.stdout.write(f"{progress_prefix}... SKIPPED (no changes)\n")
280
+ sys.stdout.flush()
271
281
 
272
282
  completed += 1
273
283
  logger.info("Commit %s (%d/%d) resolved and committed.", current_commit, completed, total)
@@ -319,15 +329,35 @@ class Synchronizer:
319
329
 
320
330
  def _execute_replay_queue(self, commits: List[str], total_commits: int, start_index: int) -> None:
321
331
  """Execute a list of commits sequentially, catching conflicts and persisting state."""
332
+ pad_width = len(str(total_commits))
322
333
  for idx, commit_hash in enumerate(commits, start=start_index):
323
334
  commit_msg = self.git.get_commit_message(commit_hash)
324
335
  first_line = commit_msg.splitlines()[0] if commit_msg else ""
325
- logger.info("[%d/%d] Applying commit %s: %s", idx, total_commits, commit_hash[:8], first_line)
336
+ progress_prefix = f"[{idx:>{pad_width}}/{total_commits}] Applying {commit_hash[:8]}: {first_line}"
337
+ logger.info(progress_prefix)
338
+
339
+ # Interactive console progress output
340
+ if sys.stdout.isatty():
341
+ sys.stdout.write(f"\r\033[K{progress_prefix}... ")
342
+ sys.stdout.flush()
343
+ else:
344
+ sys.stdout.write(f"{progress_prefix}... ")
345
+ sys.stdout.flush()
326
346
 
327
347
  try:
328
348
  self._patch_and_stage_commit(commit_hash)
329
349
  self.svn.commit(commit_msg)
350
+ sys.stdout.write("OK\n")
351
+ sys.stdout.flush()
352
+ except SvnError:
353
+ sys.stdout.write("FAILED (SVN error)\n")
354
+ sys.stdout.flush()
355
+ # SVN operational failure (e.g. working copy locked, out-of-date, collision)
356
+ # Re-raise directly to display actionable SVN resolution hints
357
+ raise
330
358
  except Exception:
359
+ sys.stdout.write("CONFLICT\n")
360
+ sys.stdout.flush()
331
361
  remaining = commits[idx - start_index + 1 :]
332
362
  state_data = {
333
363
  "state": "CONFLICT_PAUSED",
@@ -483,3 +513,121 @@ class Synchronizer:
483
513
  print(f"[DRY-RUN] Ensure directory exists: {target_file.parent}")
484
514
  else:
485
515
  target_file.parent.mkdir(parents=True, exist_ok=True)
516
+
517
+ def status(self) -> int:
518
+ """
519
+ Inspect and display status of Git repo, SVN working copy, in-progress replay, and pending commits.
520
+ Returns:
521
+ 0 if clean and ready / in sync
522
+ 1 if errors, working copy locked, merge conflicts, or linear violations exist
523
+ """
524
+ has_error = False
525
+
526
+ # 1. Check in-progress replay state
527
+ state = load_replay_state(self.svn.workspace_dir)
528
+ if state:
529
+ has_error = True
530
+ current_commit = state.get("current_commit", "unknown")
531
+ current_msg = state.get("current_commit_msg", "")
532
+ first_msg = current_msg.splitlines()[0] if current_msg else ""
533
+ remaining = state.get("remaining_commits", [])
534
+ completed = state.get("completed_commits", 0)
535
+ total = state.get("total_commits", len(remaining) + 1)
536
+
537
+ print("[Replay In Progress]")
538
+ print(f' State : PAUSED (conflict at commit {current_commit[:8]} "{first_msg}")')
539
+ print(f" Progress : {completed} of {total} commits applied ({len(remaining)} remaining)")
540
+
541
+ rej_files = find_conflict_artifacts(self.svn.workspace_dir)
542
+ if rej_files:
543
+ rel_rejs = [str(r.relative_to(self.svn.workspace_dir)) for r in rej_files]
544
+ print(f" Conflicts : {', '.join(rel_rejs)}")
545
+ print(" Action : Resolve conflicts and run 'git2svn replay --continue' (or '--abort' / '--skip')\n")
546
+
547
+ # 2. Git Workspace Status
548
+ git_clean = self.git.is_clean()
549
+ branch = self.git.get_current_branch()
550
+ head_commit = self.git.get_head_commit()
551
+ head_subject = self.git.get_head_subject()
552
+ head_desc = f'{head_commit} "{head_subject}"' if head_subject else head_commit
553
+
554
+ print("[Git Workspace]")
555
+ print(f" Repository: {self.git.repo_dir}")
556
+ print(f" Branch : {branch} (at {head_desc})")
557
+ print(f" Tree : {'Clean' if git_clean else 'Dirty (uncommitted changes present)'}")
558
+
559
+ # 3. SVN Working Copy Status
560
+ print("\n[SVN Working Copy]")
561
+ print(f" Path : {self.svn.workspace_dir}")
562
+ svn_info = self.svn.get_info()
563
+ svn_url = svn_info.get("URL") or svn_info.get("Relative URL") or "unknown"
564
+ svn_rev = svn_info.get("Revision")
565
+ rev_str = f" (r{svn_rev})" if svn_rev else ""
566
+ print(f" Target : {svn_url}{rev_str}")
567
+
568
+ try:
569
+ svn_clean = self.svn.is_clean()
570
+ if svn_clean:
571
+ print(" Tree : Clean (no uncommitted changes, unlocked)")
572
+ else:
573
+ uncommitted = self.svn.get_status_summary()
574
+ print(f" Tree : Dirty ({len(uncommitted)} uncommitted changes)")
575
+ for item in uncommitted[:5]:
576
+ print(f" {item}")
577
+ if len(uncommitted) > 5:
578
+ print(f" ... and {len(uncommitted) - 5} more")
579
+ except SvnError as e:
580
+ has_error = True
581
+ print(f" Tree : Error: {e.args[0].splitlines()[0]}")
582
+
583
+ # 4. Synchronization Queue & Default Range
584
+ default_range = self.git.get_config("git2svn.defaultRange")
585
+ print("\n[Synchronization]")
586
+ if not default_range:
587
+ print(" Range : Not configured (run 'git2svn setup' or 'git config git2svn.defaultRange <range>')")
588
+ else:
589
+ print(f" Range : {default_range}")
590
+ try:
591
+ is_single, start_ref, end_ref = parse_ref_arguments(default_range)
592
+ if is_single:
593
+ commits = [start_ref] if self.git.ref_exists(start_ref) else []
594
+ merges = []
595
+ else:
596
+ assert end_ref is not None
597
+ if not self.git.ref_exists(start_ref):
598
+ print(f" Warning : Range start ref '{start_ref}' not found in Git.")
599
+ commits = []
600
+ merges = []
601
+ elif not self.git.ref_exists(end_ref):
602
+ print(f" Warning : Range end ref '{end_ref}' not found in Git.")
603
+ commits = []
604
+ merges = []
605
+ else:
606
+ merges = self.git.get_merge_commits(start_ref, end_ref)
607
+ commits = self.git.get_commit_range(start_ref, end_ref)
608
+
609
+ if merges:
610
+ has_error = True
611
+ print(
612
+ f" Linearity : INVALID ({len(merges)} merge commits found in range - linear rebase required)"
613
+ )
614
+ else:
615
+ print(" Linearity : OK (strictly linear)")
616
+
617
+ if not commits:
618
+ print(" Pending : In sync (0 commits to replay)")
619
+ else:
620
+ print(f" Pending : {len(commits)} commit(s) ready to replay:")
621
+ for idx, c_hash in enumerate(commits[:10], start=1):
622
+ msg = self.git.get_commit_message(c_hash)
623
+ subject = msg.splitlines()[0] if msg else ""
624
+ print(f" {idx}. [{c_hash[:8]}] {subject}")
625
+ if len(commits) > 10:
626
+ print(f" ... and {len(commits) - 10} more")
627
+ except Exception as e:
628
+ print(f" Error : Could not parse range '{default_range}': {e}")
629
+ has_error = True
630
+
631
+ if has_error:
632
+ return 1
633
+ return 0
@@ -187,6 +187,11 @@ class GitRepo:
187
187
  return branch if branch != "HEAD" else "HEAD (detached)"
188
188
  return "unknown"
189
189
 
190
+ def is_clean(self) -> bool:
191
+ """Check if Git working tree and index have no uncommitted changes."""
192
+ res = self.run_cmd(["status", "--porcelain"], check=False)
193
+ return not bool(res.stdout.strip())
194
+
190
195
  def get_head_commit(self) -> str:
191
196
  """Return the short hash of HEAD."""
192
197
  res = self.run_cmd(["rev-parse", "--short", "HEAD"], check=False)
@@ -194,6 +199,18 @@ class GitRepo:
194
199
  return res.stdout.strip()
195
200
  return "unknown"
196
201
 
202
+ def get_head_subject(self) -> str:
203
+ """Return the subject line of the HEAD commit."""
204
+ res = self.run_cmd(["log", "-1", "--format=%s", "HEAD"], check=False)
205
+ if res.returncode == 0:
206
+ return res.stdout.strip()
207
+ return ""
208
+
209
+ def ref_exists(self, ref: str) -> bool:
210
+ """Check whether a Git reference or revision expression resolves."""
211
+ res = self.run_cmd(["rev-parse", "--verify", "--quiet", ref], check=False)
212
+ return res.returncode == 0
213
+
197
214
  def get_commit_parent(self, commit_hash: str) -> Optional[str]:
198
215
  """Return the parent commit hash, or None if root commit."""
199
216
  res = self.run_cmd(["rev-parse", "--verify", f"{commit_hash}^"], check=False)
@@ -201,15 +218,15 @@ class GitRepo:
201
218
  return res.stdout.strip()
202
219
  return None
203
220
 
204
- def get_diff(self, ref_spec: str) -> str:
205
- """Get unified diff for the given reference specification."""
206
- 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])
207
224
  return res.stdout
208
225
 
209
- def get_diff_root(self, commit_hash: str) -> str:
210
- """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."""
211
228
  empty_tree_hash = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
212
- res = self.run_cmd(["diff", "--binary", empty_tree_hash, commit_hash])
229
+ res = self.run_cmd_bytes(["diff", "--binary", empty_tree_hash, commit_hash])
213
230
  return res.stdout
214
231
 
215
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)
@@ -32,6 +32,72 @@ def find_svn_binary() -> str:
32
32
  return "svn"
33
33
 
34
34
 
35
+ class SvnError(RuntimeError):
36
+ """Base exception for Subversion command failures."""
37
+
38
+ def __init__(self, message: str, stderr: str = "", returncode: int = 1):
39
+ super().__init__(message)
40
+ self.stderr = stderr
41
+ self.returncode = returncode
42
+
43
+
44
+ class SvnLockError(SvnError):
45
+ """Raised when the SVN working copy or a file is locked (e.g. E155004)."""
46
+
47
+ pass
48
+
49
+
50
+ class SvnOutOfDateError(SvnError):
51
+ """Raised when an SVN commit or update fails due to out-of-date items or collision (e.g. E155015 / E160024)."""
52
+
53
+ pass
54
+
55
+
56
+ def parse_svn_error(stderr: str, action_desc: str, svn_dir: Path) -> SvnError:
57
+ """Analyze Subversion stderr and construct actionable SvnError with cleanup hints."""
58
+ lower_err = stderr.lower()
59
+ clean_stderr = stderr.strip()
60
+
61
+ # 1. Working copy locked (E155004 / "is already locked" / "run 'svn cleanup'")
62
+ if (
63
+ "e155004" in lower_err
64
+ or "working copy locked" in lower_err
65
+ or "already locked" in lower_err
66
+ or "run 'svn cleanup'" in lower_err
67
+ ):
68
+ hint = (
69
+ f"Subversion working copy at '{svn_dir}' is locked.\n"
70
+ f"Details from SVN:\n {clean_stderr}\n\n"
71
+ f"To resolve:\n"
72
+ f' 1. Run: svn cleanup "{svn_dir}"\n'
73
+ f" 2. If a background process (IDE, mirror sync, indexing) is running, wait for it to finish.\n"
74
+ f" 3. Retry your git2svn command."
75
+ )
76
+ return SvnLockError(hint, stderr=clean_stderr)
77
+
78
+ # 2. Out of date / collision (E155015 / E160024 / E155011 / "out of date" / "item is out of date")
79
+ if (
80
+ "e155015" in lower_err
81
+ or "e160024" in lower_err
82
+ or "e155011" in lower_err
83
+ or "out of date" in lower_err
84
+ or "conflict" in lower_err
85
+ ):
86
+ hint = (
87
+ f"Subversion working copy at '{svn_dir}' is out of date or conflicted.\n"
88
+ f"Details from SVN:\n {clean_stderr}\n\n"
89
+ f"To resolve:\n"
90
+ f' 1. Run: svn update "{svn_dir}"\n'
91
+ f" 2. Resolve any SVN conflicts if present ('svn status').\n"
92
+ f" 3. Retry your git2svn command (e.g. 'git2svn replay --continue' or retry replay)."
93
+ )
94
+ return SvnOutOfDateError(hint, stderr=clean_stderr)
95
+
96
+ # Generic SVN error with context
97
+ msg = f"Failed to {action_desc} in '{svn_dir}':\n {clean_stderr}"
98
+ return SvnError(msg, stderr=clean_stderr)
99
+
100
+
35
101
  class SvnWorkspace:
36
102
  """Wrapper around SVN commands and filesystem staging operations."""
37
103
 
@@ -83,8 +149,17 @@ class SvnWorkspace:
83
149
  def is_clean(self) -> bool:
84
150
  """Check if SVN workspace has no uncommitted changes."""
85
151
  res = self.run_cmd(["status", "-q"], check=False)
152
+ if res.returncode != 0:
153
+ raise parse_svn_error(res.stderr, "check status", self.workspace_dir)
86
154
  return not bool(res.stdout.strip())
87
155
 
156
+ def get_status_summary(self) -> List[str]:
157
+ """Return lines of uncommitted changes from 'svn status -q'."""
158
+ res = self.run_cmd(["status", "-q"], check=False)
159
+ if res.returncode != 0:
160
+ raise parse_svn_error(res.stderr, "check status", self.workspace_dir)
161
+ return [line.rstrip() for line in res.stdout.splitlines() if line.strip()]
162
+
88
163
  def get_versioned_files(self) -> List[Path]:
89
164
  """
90
165
  Return list of all versioned file paths in the SVN workspace (relative to workspace_dir).
@@ -143,9 +218,7 @@ class SvnWorkspace:
143
218
  if res.returncode != 0:
144
219
  if "is already under version control" not in res.stderr and "already exists" not in res.stderr:
145
220
  logger.error("Failed to 'svn add %s': %s", posix_path, res.stderr.strip())
146
- raise subprocess.CalledProcessError(
147
- res.returncode, [self.svn_bin, "add", posix_path], res.stdout, res.stderr
148
- )
221
+ raise parse_svn_error(res.stderr, f"add '{posix_path}'", self.workspace_dir)
149
222
 
150
223
  def stage_rm(self, rel_path: Path) -> None:
151
224
  """Run svn rm <filepath>."""
@@ -156,9 +229,7 @@ class SvnWorkspace:
156
229
  logger.warning("File %s not under SVN control to remove.", posix_path)
157
230
  else:
158
231
  logger.error("Failed to 'svn rm %s': %s", posix_path, res.stderr.strip())
159
- raise subprocess.CalledProcessError(
160
- res.returncode, [self.svn_bin, "rm", posix_path], res.stdout, res.stderr
161
- )
232
+ raise parse_svn_error(res.stderr, f"remove '{posix_path}'", self.workspace_dir)
162
233
 
163
234
  def commit(self, message: str) -> None:
164
235
  """Run svn commit using a temporary file with -F to support arbitrary message lengths and encodings."""
@@ -175,9 +246,7 @@ class SvnWorkspace:
175
246
  res = self.run_cmd(["commit", "-F", str(tf_path)], check=False)
176
247
  if res.returncode != 0:
177
248
  logger.error("Failed to 'svn commit': %s", res.stderr.strip())
178
- raise subprocess.CalledProcessError(
179
- res.returncode, [self.svn_bin, "commit", "-F", str(tf_path)], res.stdout, res.stderr
180
- )
249
+ raise parse_svn_error(res.stderr, "commit", self.workspace_dir)
181
250
  if res.stdout:
182
251
  logger.info("SVN commit output:\n%s", res.stdout.strip())
183
252
  finally:
@@ -220,6 +289,6 @@ class SvnWorkspace:
220
289
  res = self.run_cmd(["update"], check=False)
221
290
  if res.returncode != 0:
222
291
  logger.error("Failed to 'svn update': %s", res.stderr.strip())
223
- raise subprocess.CalledProcessError(res.returncode, [self.svn_bin, "update"], res.stdout, res.stderr)
292
+ raise parse_svn_error(res.stderr, "update", self.workspace_dir)
224
293
  if res.stdout:
225
294
  logger.info("SVN update output:\n%s", res.stdout.strip())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git2svn
3
- Version: 0.4.1
3
+ Version: 0.5.1
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
@@ -35,7 +35,7 @@ Dynamic: license-file
35
35
  <p align="center">
36
36
  <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
37
  <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>
38
+ <a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=green" alt="PyPI Version"></a>
39
39
  <img src="https://img.shields.io/badge/Python-v3.11+-3776AB?logo=python&logoColor=white" alt="Python Version">
40
40
  <img src="https://img.shields.io/endpoint?url=https%3A%2F%2Ftokei.kojix2.net%2Fbadge%2Fgithub%2Fsimon123h%2Fgit2svn%2Flines" alt="Lines of Code">
41
41
  <a href="https://github.com/simon123h/git2svn/releases"><img src="https://badgen.net/github/release/simon123h/git2svn?color=orange" alt="Latest Release"></a>
@@ -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)
@@ -667,6 +667,34 @@ class TestSynchronizer(unittest.TestCase):
667
667
  self.assertTrue((self.svn_path / "trunk_feat.txt").is_file())
668
668
  self.assertEqual((self.svn_path / "trunk_feat.txt").read_text(), "trunk feat\n")
669
669
 
670
+ @patch.object(git2svn.SvnWorkspace, "run_cmd")
671
+ def test_replay_progress_output(self, mock_svn_cmd):
672
+ """Verify git2svn replay outputs formatted progress indicators with commit title and OK status."""
673
+ import io
674
+
675
+ mock_svn_cmd.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
676
+
677
+ base_file = self.git_path / "prog_base.txt"
678
+ base_file.write_text("prog base\n")
679
+ subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
680
+ subprocess.run(["git", "commit", "-m", "prog base"], cwd=self.git_path, check=True)
681
+ base_hash = subprocess.run(
682
+ ["git", "rev-parse", "HEAD"], cwd=self.git_path, capture_output=True, text=True
683
+ ).stdout.strip()
684
+
685
+ commit_file = self.git_path / "prog_feat.txt"
686
+ commit_file.write_text("prog feat\n")
687
+ subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
688
+ subprocess.run(["git", "commit", "-m", "feat: progress indicator"], cwd=self.git_path, check=True)
689
+ feat_hash = subprocess.run(
690
+ ["git", "rev-parse", "HEAD"], cwd=self.git_path, capture_output=True, text=True
691
+ ).stdout.strip()
692
+
693
+ with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
694
+ self.sync_mgr.replay(base_hash, feat_hash)
695
+ output = mock_stdout.getvalue()
696
+ self.assertIn(f"[1/1] Applying {feat_hash[:8]}: feat: progress indicator... OK", output)
697
+
670
698
  @patch.object(git2svn.SvnWorkspace, "run_cmd")
671
699
  def test_stage_with_git_config_default_range(self, mock_svn_cmd):
672
700
  """Verify git2svn stage with no ref arguments falls back to git2svn.defaultRange."""
@@ -954,6 +982,7 @@ class TestEolUtilities(unittest.TestCase):
954
982
  self.assertEqual(git_repo.get_config("pull.ff"), "only")
955
983
  self.assertIn("git2svn replay", git_repo.get_config("alias.svn-push"))
956
984
  self.assertIn("git fetch svn-mirror", git_repo.get_config("alias.svn-pull"))
985
+ self.assertEqual(git_repo.get_config("alias.svn-status"), "!git2svn status")
957
986
 
958
987
  def test_setup_interactive_input(self):
959
988
  """Verify git2svn setup prompts for svn path via input() when tty."""
@@ -1040,5 +1069,199 @@ class TestEolUtilities(unittest.TestCase):
1040
1069
  self.assertEqual(git2svn.svn.find_svn_binary(), "svn")
1041
1070
 
1042
1071
 
1072
+ class TestSvnLockAndCollisionHandling(unittest.TestCase):
1073
+ def setUp(self):
1074
+ self.temp_dir = tempfile.TemporaryDirectory()
1075
+ self.path = Path(self.temp_dir.name).resolve()
1076
+ self.workspace = git2svn.SvnWorkspace(self.path)
1077
+
1078
+ def tearDown(self):
1079
+ self.temp_dir.cleanup()
1080
+
1081
+ def test_parse_svn_error_lock(self):
1082
+ stderr = "svn: E155004: Working copy '/tmp/svn_wc' locked.\nsvn: run 'svn cleanup' to remove locks"
1083
+ err = git2svn.svn.parse_svn_error(stderr, "commit", self.path)
1084
+ self.assertIsInstance(err, git2svn.SvnLockError)
1085
+ self.assertIn("is locked", str(err))
1086
+ self.assertIn("svn cleanup", str(err))
1087
+
1088
+ def test_parse_svn_error_out_of_date(self):
1089
+ stderr = "svn: E155015: Aborting commit: 'file.txt' remains in conflict / item is out of date"
1090
+ err = git2svn.svn.parse_svn_error(stderr, "commit", self.path)
1091
+ self.assertIsInstance(err, git2svn.SvnOutOfDateError)
1092
+ self.assertIn("out of date", str(err))
1093
+ self.assertIn("svn update", str(err))
1094
+
1095
+ def test_parse_svn_error_e160024_conflict(self):
1096
+ stderr = "svn: E160024: resource out of date; try updating"
1097
+ err = git2svn.svn.parse_svn_error(stderr, "commit", self.path)
1098
+ self.assertIsInstance(err, git2svn.SvnOutOfDateError)
1099
+ self.assertIn("svn update", str(err))
1100
+
1101
+ @patch.object(git2svn.SvnWorkspace, "run_cmd")
1102
+ def test_commit_raises_lock_error_with_hints(self, mock_cmd):
1103
+ mock_cmd.return_value = subprocess.CompletedProcess(
1104
+ [], 1, stdout="", stderr="svn: E155004: Working copy locked; please run 'svn cleanup'"
1105
+ )
1106
+ with self.assertRaises(git2svn.SvnLockError) as cm:
1107
+ self.workspace.commit("test msg")
1108
+ self.assertIn("svn cleanup", str(cm.exception))
1109
+
1110
+ @patch.object(git2svn.SvnWorkspace, "run_cmd")
1111
+ def test_commit_raises_out_of_date_error_with_hints(self, mock_cmd):
1112
+ mock_cmd.return_value = subprocess.CompletedProcess(
1113
+ [], 1, stdout="", stderr="svn: E155015: Commit failed because item is out of date"
1114
+ )
1115
+ with self.assertRaises(git2svn.SvnOutOfDateError) as cm:
1116
+ self.workspace.commit("test msg")
1117
+ self.assertIn("svn update", str(cm.exception))
1118
+
1119
+ @patch.object(git2svn.SvnWorkspace, "run_cmd")
1120
+ def test_update_raises_lock_error(self, mock_cmd):
1121
+ mock_cmd.return_value = subprocess.CompletedProcess(
1122
+ [], 1, stdout="", stderr="svn: E155004: Working copy locked."
1123
+ )
1124
+ with self.assertRaises(git2svn.SvnLockError):
1125
+ self.workspace.update()
1126
+
1127
+ @patch.object(git2svn.SvnWorkspace, "run_cmd")
1128
+ def test_is_clean_raises_on_lock(self, mock_cmd):
1129
+ mock_cmd.return_value = subprocess.CompletedProcess(
1130
+ [], 1, stdout="", stderr="svn: E155004: Working copy locked."
1131
+ )
1132
+ with self.assertRaises(git2svn.SvnLockError):
1133
+ self.workspace.is_clean()
1134
+
1135
+
1136
+ class TestStatus(unittest.TestCase):
1137
+ def setUp(self):
1138
+ self.temp_dir = tempfile.TemporaryDirectory()
1139
+ self.path = Path(self.temp_dir.name).resolve()
1140
+ self.git_path = self.path / "git_repo"
1141
+ self.svn_path = self.path / "svn_repo"
1142
+ self.git_path.mkdir()
1143
+ self.svn_path.mkdir()
1144
+ (self.svn_path / ".svn").mkdir()
1145
+
1146
+ subprocess.run(["git", "init"], cwd=self.git_path, check=True, capture_output=True)
1147
+ subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.git_path, check=True)
1148
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=self.git_path, check=True)
1149
+
1150
+ self.git_repo = git2svn.GitRepo(self.git_path)
1151
+ self.svn_workspace = git2svn.SvnWorkspace(self.svn_path)
1152
+ self.patcher = git2svn.Patcher(self.svn_path)
1153
+ self.sync_mgr = git2svn.Synchronizer(self.git_repo, self.svn_workspace, self.patcher)
1154
+
1155
+ def tearDown(self):
1156
+ self.temp_dir.cleanup()
1157
+
1158
+ def test_status_clean_no_commits(self):
1159
+ """Status succeeds when both repos are clean and range has no pending commits."""
1160
+ f = self.git_path / "init.txt"
1161
+ f.write_text("hello\n")
1162
+ subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
1163
+ subprocess.run(["git", "commit", "-m", "init commit"], cwd=self.git_path, check=True)
1164
+ head = self.git_repo.get_head_commit()
1165
+
1166
+ self.git_repo.set_config("git2svn.defaultRange", f"{head}..{head}")
1167
+
1168
+ with (
1169
+ patch.object(self.svn_workspace, "is_clean", return_value=True),
1170
+ patch.object(self.svn_workspace, "get_info", return_value={"URL": "file:///svn/trunk", "Revision": "10"}),
1171
+ ):
1172
+ code = self.sync_mgr.status()
1173
+ self.assertEqual(code, 0)
1174
+
1175
+ def test_status_pending_commits(self):
1176
+ """Status succeeds and reports pending commits."""
1177
+ f = self.git_path / "base.txt"
1178
+ f.write_text("base\n")
1179
+ subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
1180
+ subprocess.run(["git", "commit", "-m", "base"], cwd=self.git_path, check=True)
1181
+ base = self.git_repo.get_head_commit()
1182
+
1183
+ f2 = self.git_path / "feat.txt"
1184
+ f2.write_text("feat\n")
1185
+ subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
1186
+ subprocess.run(["git", "commit", "-m", "feat: new feature"], cwd=self.git_path, check=True)
1187
+ head = self.git_repo.get_head_commit()
1188
+
1189
+ self.git_repo.set_config("git2svn.defaultRange", f"{base}..{head}")
1190
+
1191
+ with (
1192
+ patch.object(self.svn_workspace, "is_clean", return_value=True),
1193
+ patch.object(self.svn_workspace, "get_info", return_value={"URL": "file:///svn/trunk", "Revision": "10"}),
1194
+ ):
1195
+ code = self.sync_mgr.status()
1196
+ self.assertEqual(code, 0)
1197
+
1198
+ def test_status_detects_merge_commits_as_error(self):
1199
+ """Status exits with code 1 if merge commits exist in the range."""
1200
+ f = self.git_path / "base.txt"
1201
+ f.write_text("base\n")
1202
+ subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
1203
+ subprocess.run(["git", "commit", "-m", "base"], cwd=self.git_path, check=True)
1204
+ base = self.git_repo.get_head_commit()
1205
+
1206
+ init_branch = self.git_repo.get_current_branch()
1207
+ subprocess.run(["git", "checkout", "-b", "side"], cwd=self.git_path, check=True, capture_output=True)
1208
+ f_side = self.git_path / "side.txt"
1209
+ f_side.write_text("side\n")
1210
+ subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
1211
+ subprocess.run(["git", "commit", "-m", "side commit"], cwd=self.git_path, check=True)
1212
+
1213
+ subprocess.run(["git", "checkout", init_branch], cwd=self.git_path, check=True, capture_output=True)
1214
+ f_main = self.git_path / "main.txt"
1215
+ f_main.write_text("main\n")
1216
+ subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
1217
+ subprocess.run(["git", "commit", "-m", "main commit"], cwd=self.git_path, check=True)
1218
+
1219
+ # Create merge commit
1220
+ subprocess.run(["git", "merge", "--no-ff", "side", "-m", "merge side"], cwd=self.git_path, check=True)
1221
+ head = self.git_repo.get_head_commit()
1222
+
1223
+ self.git_repo.set_config("git2svn.defaultRange", f"{base}..{head}")
1224
+
1225
+ with (
1226
+ patch.object(self.svn_workspace, "is_clean", return_value=True),
1227
+ patch.object(self.svn_workspace, "get_info", return_value={"URL": "file:///svn/trunk", "Revision": "10"}),
1228
+ ):
1229
+ code = self.sync_mgr.status()
1230
+ self.assertEqual(code, 1)
1231
+
1232
+ def test_status_detects_paused_replay_state(self):
1233
+ """Status exits with code 1 when an in-progress replay is paused."""
1234
+ git2svn.save_replay_state(
1235
+ self.svn_path,
1236
+ {
1237
+ "state": "CONFLICT_PAUSED",
1238
+ "current_commit": "12345678",
1239
+ "current_commit_msg": "conflict commit",
1240
+ "remaining_commits": ["abcdef12"],
1241
+ "completed_commits": 1,
1242
+ "total_commits": 3,
1243
+ },
1244
+ )
1245
+ try:
1246
+ with (
1247
+ patch.object(self.svn_workspace, "is_clean", return_value=True),
1248
+ patch.object(
1249
+ self.svn_workspace, "get_info", return_value={"URL": "file:///svn/trunk", "Revision": "10"}
1250
+ ),
1251
+ ):
1252
+ code = self.sync_mgr.status()
1253
+ self.assertEqual(code, 1)
1254
+ finally:
1255
+ git2svn.clear_replay_state(self.svn_path)
1256
+
1257
+ def test_status_cli_dispatch(self):
1258
+ """Verify git2svn status CLI command executes correctly."""
1259
+ self.git_repo.set_config("git2svn.svnDir", str(self.svn_path))
1260
+ with patch.object(git2svn.Synchronizer, "status", return_value=0) as mock_status:
1261
+ code = git2svn.main(["--git-dir", str(self.git_path), "status"])
1262
+ self.assertEqual(code, 0)
1263
+ mock_status.assert_called_once()
1264
+
1265
+
1043
1266
  if __name__ == "__main__":
1044
1267
  unittest.main()
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