git2svn 0.7.1__tar.gz → 0.8.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 (51) hide show
  1. {git2svn-0.7.1/git2svn.egg-info → git2svn-0.8.0}/PKG-INFO +1 -1
  2. {git2svn-0.7.1 → git2svn-0.8.0}/docs/user-guide/commands.md +24 -3
  3. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/_version.py +2 -2
  4. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/cli.py +32 -0
  5. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/core.py +27 -9
  6. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/svn.py +15 -0
  7. {git2svn-0.7.1 → git2svn-0.8.0/git2svn.egg-info}/PKG-INFO +1 -1
  8. {git2svn-0.7.1 → git2svn-0.8.0}/tests/test_cli.py +33 -0
  9. {git2svn-0.7.1 → git2svn-0.8.0}/tests/test_core.py +15 -1
  10. {git2svn-0.7.1 → git2svn-0.8.0}/tests/test_svn.py +14 -0
  11. {git2svn-0.7.1 → git2svn-0.8.0}/.githooks/pre-commit +0 -0
  12. {git2svn-0.7.1 → git2svn-0.8.0}/.github/dependabot.yml +0 -0
  13. {git2svn-0.7.1 → git2svn-0.8.0}/.github/workflows/ci.yml +0 -0
  14. {git2svn-0.7.1 → git2svn-0.8.0}/.github/workflows/release.yml +0 -0
  15. {git2svn-0.7.1 → git2svn-0.8.0}/.gitignore +0 -0
  16. {git2svn-0.7.1 → git2svn-0.8.0}/.pre-commit-config.yaml +0 -0
  17. {git2svn-0.7.1 → git2svn-0.8.0}/CONTRIBUTING.md +0 -0
  18. {git2svn-0.7.1 → git2svn-0.8.0}/LICENSE.md +0 -0
  19. {git2svn-0.7.1 → git2svn-0.8.0}/README.md +0 -0
  20. {git2svn-0.7.1 → git2svn-0.8.0}/docs/arc42/README.md +0 -0
  21. {git2svn-0.7.1 → git2svn-0.8.0}/docs/arc42/adrs.md +0 -0
  22. {git2svn-0.7.1 → git2svn-0.8.0}/docs/arc42/concepts.md +0 -0
  23. {git2svn-0.7.1 → git2svn-0.8.0}/docs/req42.md +0 -0
  24. {git2svn-0.7.1 → git2svn-0.8.0}/docs/user-guide/README.md +0 -0
  25. {git2svn-0.7.1 → git2svn-0.8.0}/docs/user-guide/integration-workflow.md +0 -0
  26. {git2svn-0.7.1 → git2svn-0.8.0}/docs/user-guide/troubleshooting.md +0 -0
  27. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/__init__.py +0 -0
  28. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/__main__.py +0 -0
  29. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/colors.py +0 -0
  30. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/eol.py +0 -0
  31. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/git.py +0 -0
  32. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/patcher.py +0 -0
  33. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/py.typed +0 -0
  34. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/setup.py +0 -0
  35. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/state.py +0 -0
  36. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn/status.py +0 -0
  37. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn.egg-info/SOURCES.txt +0 -0
  38. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn.egg-info/dependency_links.txt +0 -0
  39. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn.egg-info/entry_points.txt +0 -0
  40. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn.egg-info/requires.txt +0 -0
  41. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn.egg-info/scm_file_list.json +0 -0
  42. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn.egg-info/top_level.txt +0 -0
  43. {git2svn-0.7.1 → git2svn-0.8.0}/git2svn.py +0 -0
  44. {git2svn-0.7.1 → git2svn-0.8.0}/pyproject.toml +0 -0
  45. {git2svn-0.7.1 → git2svn-0.8.0}/setup.cfg +0 -0
  46. {git2svn-0.7.1 → git2svn-0.8.0}/tests/test_colors.py +0 -0
  47. {git2svn-0.7.1 → git2svn-0.8.0}/tests/test_e2e.py +0 -0
  48. {git2svn-0.7.1 → git2svn-0.8.0}/tests/test_eol.py +0 -0
  49. {git2svn-0.7.1 → git2svn-0.8.0}/tests/test_git.py +0 -0
  50. {git2svn-0.7.1 → git2svn-0.8.0}/tests/test_patcher.py +0 -0
  51. {git2svn-0.7.1 → git2svn-0.8.0}/tests/test_status.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git2svn
3
- Version: 0.7.1
3
+ Version: 0.8.0
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
@@ -1,6 +1,6 @@
1
- # Command Reference: `setup`, `stage` & `replay`
1
+ # Command Reference: `stage`, `diff`, `replay`, `setup` & `status`
2
2
 
3
- This document details the usage, flags, and mechanics for `git2svn setup`, `git2svn stage`, and `git2svn replay`.
3
+ This document details the usage, flags, and mechanics for all `git2svn` commands.
4
4
 
5
5
 
6
6
  ---
@@ -43,9 +43,30 @@ Compares the full tree of a target Git branch or commit against the SVN workspac
43
43
  git2svn stage feature/login --snapshot -s /path/to/svn
44
44
  ```
45
45
 
46
+ ### 1.5 Immediate Diff Preview (`--diff` / `-p`)
47
+ Immediately preview the staged diff upon completion without needing to run a separate command:
48
+
49
+ ```bash
50
+ git2svn stage main..feature/login --diff
51
+ ```
52
+
53
+ ---
54
+
55
+ ## 2. Command: `diff`
56
+
57
+ Inspect uncommitted changes in the SVN workspace staged by `git2svn stage`.
58
+
59
+ ```bash
60
+ # View full unified diff of staged changes:
61
+ git2svn diff
62
+
63
+ # View compact diffstat summary:
64
+ git2svn diff --stat
65
+ ```
66
+
46
67
  ---
47
68
 
48
- ## 2. Command: `replay`
69
+ ## 3. Command: `replay`
49
70
 
50
71
  Sequentially ports Git commits onto the SVN workspace, creating an **atomic `svn commit` for each** with its original Git commit message, author body, and timestamps.
51
72
 
@@ -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.7.1'
22
- __version_tuple__ = version_tuple = (0, 7, 1)
21
+ __version__ = version = '0.8.0'
22
+ __version_tuple__ = version_tuple = (0, 8, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -136,6 +136,13 @@ def build_parser() -> argparse.ArgumentParser:
136
136
  default=argparse.SUPPRESS,
137
137
  help="Mirror the exact tree of ref1 onto SVN without knowing base ref (adds new, removes missing, updates modified)",
138
138
  )
139
+ parser_stage.add_argument(
140
+ "--diff",
141
+ "-p",
142
+ action="store_true",
143
+ default=argparse.SUPPRESS,
144
+ help="Immediately show 'svn diff' preview of staged changes after staging",
145
+ )
139
146
 
140
147
  # replay
141
148
  parser_replay = subparsers.add_parser(
@@ -214,6 +221,20 @@ def build_parser() -> argparse.ArgumentParser:
214
221
  help="Path to SVN working copy (optional if already configured or set via --svn-dir)",
215
222
  )
216
223
 
224
+ # diff
225
+ parser_diff = subparsers.add_parser(
226
+ "diff",
227
+ parents=[common_parser],
228
+ help="Inspect uncommitted changes in the SVN workspace (preview after staging)",
229
+ description="Run svn diff on the SVN workspace to inspect uncommitted changes staged by git2svn.",
230
+ )
231
+ parser_diff.add_argument(
232
+ "--stat",
233
+ action="store_true",
234
+ default=False,
235
+ help="Display a diffstat summary of changed files instead of the full patch",
236
+ )
237
+
217
238
  # status
218
239
  subparsers.add_parser(
219
240
  "status",
@@ -249,6 +270,8 @@ def parse_cli_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
249
270
  verbose=False,
250
271
  copy=False,
251
272
  snapshot=False,
273
+ diff=False,
274
+ stat=False,
252
275
  replay_action=None,
253
276
  update=None,
254
277
  )
@@ -380,6 +403,15 @@ def main(argv: Optional[List[str]] = None) -> int:
380
403
  use_copy=use_copy,
381
404
  snapshot=is_snapshot,
382
405
  )
406
+ if getattr(args, "diff", False):
407
+ diff_output = sync_mgr.diff()
408
+ if diff_output.strip():
409
+ print(diff_output, end="")
410
+ elif args.command == "diff":
411
+ show_stat = getattr(args, "stat", False)
412
+ diff_output = sync_mgr.diff(stat=show_stat)
413
+ if diff_output.strip():
414
+ print(diff_output, end="")
383
415
  elif args.command == "replay":
384
416
  action = getattr(args, "replay_action", None)
385
417
  if action == "continue":
@@ -4,6 +4,7 @@ import logging
4
4
  import os
5
5
  import shutil
6
6
  import sys
7
+ import time
7
8
  from pathlib import Path
8
9
  from typing import Dict, List, Optional
9
10
 
@@ -210,6 +211,13 @@ class Synchronizer:
210
211
  modified_count,
211
212
  )
212
213
 
214
+ def diff(self, stat: bool = False) -> str:
215
+ """
216
+ Inspect uncommitted changes in the SVN workspace using svn diff.
217
+ Returns the diff output text (or stat summary if stat=True).
218
+ """
219
+ return self.svn.diff(stat=stat)
220
+
213
221
  def replay(self, ref1: str, ref2: Optional[str] = None) -> None:
214
222
  """
215
223
  Replay a single commit or range of commits onto SVN, committing each with its Git message.
@@ -218,6 +226,11 @@ class Synchronizer:
218
226
  target_spec = f"{start_or_commit}..{end_ref}" if not is_single else start_or_commit
219
227
  self.show_identity_banner(target_spec)
220
228
 
229
+ if not self.dry_run and not self.svn.is_clean():
230
+ raise RuntimeError(
231
+ "SVN workspace has uncommitted changes. Please commit, stash, or revert them before starting a replay."
232
+ )
233
+
221
234
  if is_single:
222
235
  commit_hash = start_or_commit
223
236
  logger.info("Replaying single commit %s", commit_hash)
@@ -226,11 +239,6 @@ class Synchronizer:
226
239
  start_ref = start_or_commit
227
240
  assert end_ref is not None
228
241
 
229
- if not self.dry_run and not self.svn.is_clean():
230
- raise RuntimeError(
231
- "SVN workspace has uncommitted changes. Please commit, stash, or revert them before starting a replay."
232
- )
233
-
234
242
  merges = self.git.get_merge_commits(start_ref, end_ref)
235
243
  if merges:
236
244
  raise RuntimeError(
@@ -336,6 +344,7 @@ class Synchronizer:
336
344
 
337
345
  def _execute_replay_queue(self, commits: List[str], total_commits: int, start_index: int) -> None:
338
346
  """Execute a list of commits sequentially, catching conflicts and persisting state."""
347
+ queue_start = time.perf_counter()
339
348
  pad_width = len(str(total_commits))
340
349
  for idx, commit_hash in enumerate(commits, start=start_index):
341
350
  commit_msg = self.git.get_commit_message(commit_hash)
@@ -351,19 +360,23 @@ class Synchronizer:
351
360
  sys.stdout.write(f"{progress_prefix}... ")
352
361
  sys.stdout.flush()
353
362
 
363
+ commit_start = time.perf_counter()
354
364
  try:
355
365
  self._patch_and_stage_commit(commit_hash)
356
366
  self.svn.commit(commit_msg)
357
- sys.stdout.write("OK\n")
367
+ elapsed = time.perf_counter() - commit_start
368
+ sys.stdout.write(f"OK ({elapsed:.2f}s)\n")
358
369
  sys.stdout.flush()
359
370
  except SvnError:
360
- sys.stdout.write("FAILED (SVN error)\n")
371
+ elapsed = time.perf_counter() - commit_start
372
+ sys.stdout.write(f"FAILED (SVN error, {elapsed:.2f}s)\n")
361
373
  sys.stdout.flush()
362
374
  # SVN operational failure (e.g. working copy locked, out-of-date, collision)
363
375
  # Re-raise directly to display actionable SVN resolution hints
364
376
  raise
365
377
  except Exception:
366
- sys.stdout.write("CONFLICT\n")
378
+ elapsed = time.perf_counter() - commit_start
379
+ sys.stdout.write(f"CONFLICT ({elapsed:.2f}s)\n")
367
380
  sys.stdout.flush()
368
381
  remaining = commits[idx - start_index + 1 :]
369
382
  state_data = {
@@ -398,7 +411,12 @@ class Synchronizer:
398
411
  )
399
412
  raise
400
413
  clear_replay_state(self.svn.workspace_dir)
401
- logger.info("Replay completed successfully! All %d commits applied.", total_commits)
414
+ total_elapsed = time.perf_counter() - queue_start
415
+ logger.info(
416
+ "Replay completed successfully! All %d commits applied in %.2fs.",
417
+ total_commits,
418
+ total_elapsed,
419
+ )
402
420
  if self.auto_update:
403
421
  self.svn.update()
404
422
 
@@ -153,6 +153,21 @@ class SvnWorkspace:
153
153
  raise parse_svn_error(res.stderr, "check status", self.workspace_dir)
154
154
  return not bool(res.stdout.strip())
155
155
 
156
+ def diff(self, target: Optional[str] = None, stat: bool = False) -> str:
157
+ """
158
+ Run 'svn diff' on the workspace or a specific target.
159
+ If stat is True, runs 'svn diff --stat' (summarized file changes).
160
+ """
161
+ args = ["diff"]
162
+ if stat:
163
+ args.append("--stat")
164
+ if target:
165
+ args.append(target)
166
+ res = self.run_cmd(args, check=False)
167
+ if res.returncode != 0:
168
+ raise parse_svn_error(res.stderr, "diff", self.workspace_dir)
169
+ return res.stdout
170
+
156
171
  def get_status_summary(self) -> List[str]:
157
172
  """Return lines of uncommitted changes from 'svn status -q'."""
158
173
  res = self.run_cmd(["status", "-q"], check=False)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git2svn
3
- Version: 0.7.1
3
+ Version: 0.8.0
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
@@ -193,6 +193,39 @@ class TestCli(unittest.TestCase):
193
193
  mock_is_file.return_value = False
194
194
  self.assertEqual(git2svn.svn.find_svn_binary(), "svn")
195
195
 
196
+ def test_diff_subcommand_args(self):
197
+ """Verify diff subcommand argument parsing."""
198
+ args = git2svn.parse_cli_args(["diff", "-s", "/path/to/svn"])
199
+ self.assertEqual(args.command, "diff")
200
+ self.assertFalse(args.stat)
201
+
202
+ args_stat = git2svn.parse_cli_args(["diff", "--stat", "-s", "/path/to/svn"])
203
+ self.assertEqual(args_stat.command, "diff")
204
+ self.assertTrue(args_stat.stat)
205
+
206
+ args_stage_diff = git2svn.parse_cli_args(["stage", "HEAD", "-p", "-s", "/path/to/svn"])
207
+ self.assertEqual(args_stage_diff.command, "stage")
208
+ self.assertTrue(args_stage_diff.diff)
209
+
210
+ def test_diff_command_execution(self):
211
+ """Verify git2svn diff invokes synchronizer.diff and prints output."""
212
+ git_dir = self.path / "diff_git"
213
+ git_dir.mkdir()
214
+ subprocess.run(["git", "init"], cwd=git_dir, check=True, capture_output=True)
215
+
216
+ svn_dir = self.path / "diff_svn"
217
+ svn_dir.mkdir()
218
+ (svn_dir / ".svn").mkdir()
219
+
220
+ with patch("git2svn.cli.Synchronizer") as mock_sync_cls, patch("sys.stdout", new=io.StringIO()) as fake_out:
221
+ mock_sync = mock_sync_cls.return_value
222
+ mock_sync.diff.return_value = "Index: file.txt\n=== diff content ===\n"
223
+
224
+ code = git2svn.main(["--git-dir", str(git_dir), "--svn-dir", str(svn_dir), "diff"])
225
+ self.assertEqual(code, 0)
226
+ mock_sync.diff.assert_called_once_with(stat=False)
227
+ self.assertIn("=== diff content ===", fake_out.getvalue())
228
+
196
229
 
197
230
  if __name__ == "__main__":
198
231
  unittest.main()
@@ -481,7 +481,7 @@ class TestSynchronizer(unittest.TestCase):
481
481
  with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
482
482
  self.sync_mgr.replay(base_hash, feat_hash)
483
483
  output = mock_stdout.getvalue()
484
- self.assertIn(f"[1/1] Applying {feat_hash[:8]}: feat: progress indicator... OK", output)
484
+ self.assertIn(f"[1/1] Applying {feat_hash[:8]}: feat: progress indicator... OK (", output)
485
485
 
486
486
  @patch.object(git2svn.SvnWorkspace, "run_cmd")
487
487
  def test_stage_with_git_config_default_range(self, mock_svn_cmd):
@@ -677,6 +677,20 @@ class TestSynchronizer(unittest.TestCase):
677
677
  self.assertEqual(res, 1)
678
678
  mock_tb.assert_called_once()
679
679
 
680
+ def test_replay_raises_if_svn_workspace_dirty(self):
681
+ """Verify replay raises RuntimeError if SVN workspace has uncommitted changes."""
682
+ with patch.object(self.svn_ws, "is_clean", return_value=False):
683
+ with self.assertRaises(RuntimeError) as cm:
684
+ self.sync_mgr.replay("HEAD~1..HEAD")
685
+ self.assertIn("SVN workspace has uncommitted changes", str(cm.exception))
686
+
687
+ def test_replay_single_commit_raises_if_dirty(self):
688
+ """Verify replay of single commit raises RuntimeError if SVN workspace has uncommitted changes."""
689
+ with patch.object(self.svn_ws, "is_clean", return_value=False):
690
+ with self.assertRaises(RuntimeError) as cm:
691
+ self.sync_mgr.replay("HEAD")
692
+ self.assertIn("SVN workspace has uncommitted changes", str(cm.exception))
693
+
680
694
 
681
695
  if __name__ == "__main__":
682
696
  unittest.main()
@@ -135,6 +135,20 @@ class TestSvnLockAndCollisionHandling(unittest.TestCase):
135
135
  with self.assertRaises(git2svn.SvnLockError):
136
136
  self.workspace.is_clean()
137
137
 
138
+ @patch.object(git2svn.SvnWorkspace, "run_cmd")
139
+ def test_diff(self, mock_cmd):
140
+ mock_cmd.return_value = subprocess.CompletedProcess([], 0, stdout="Index: file.txt\n", stderr="")
141
+ output = self.workspace.diff()
142
+ self.assertEqual(output, "Index: file.txt\n")
143
+ mock_cmd.assert_called_once_with(["diff"], check=False)
144
+
145
+ @patch.object(git2svn.SvnWorkspace, "run_cmd")
146
+ def test_diff_stat(self, mock_cmd):
147
+ mock_cmd.return_value = subprocess.CompletedProcess([], 0, stdout="file.txt | 2 +-\n", stderr="")
148
+ output = self.workspace.diff(stat=True)
149
+ self.assertEqual(output, "file.txt | 2 +-\n")
150
+ mock_cmd.assert_called_once_with(["diff", "--stat"], check=False)
151
+
138
152
 
139
153
  if __name__ == "__main__":
140
154
  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
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