git2svn 0.6.0__tar.gz → 0.7.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.6.0/git2svn.egg-info → git2svn-0.7.0}/PKG-INFO +1 -1
  2. {git2svn-0.6.0 → git2svn-0.7.0}/docs/arc42/README.md +24 -2
  3. {git2svn-0.6.0 → git2svn-0.7.0}/docs/arc42/adrs.md +15 -0
  4. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/__init__.py +6 -1
  5. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/_version.py +2 -2
  6. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/cli.py +23 -129
  7. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/colors.py +44 -1
  8. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/core.py +11 -127
  9. git2svn-0.7.0/git2svn/setup.py +132 -0
  10. git2svn-0.7.0/git2svn/status.py +157 -0
  11. {git2svn-0.6.0 → git2svn-0.7.0/git2svn.egg-info}/PKG-INFO +1 -1
  12. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/SOURCES.txt +10 -1
  13. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/scm_file_list.json +10 -1
  14. {git2svn-0.6.0 → git2svn-0.7.0}/pyproject.toml +1 -1
  15. git2svn-0.7.0/tests/test_cli.py +198 -0
  16. git2svn-0.7.0/tests/test_colors.py +112 -0
  17. git2svn-0.6.0/tests/test_git2svn.py → git2svn-0.7.0/tests/test_core.py +0 -637
  18. {git2svn-0.6.0 → git2svn-0.7.0}/tests/test_e2e.py +21 -0
  19. git2svn-0.7.0/tests/test_eol.py +45 -0
  20. git2svn-0.7.0/tests/test_git.py +85 -0
  21. git2svn-0.7.0/tests/test_patcher.py +33 -0
  22. git2svn-0.7.0/tests/test_status.py +140 -0
  23. git2svn-0.7.0/tests/test_svn.py +140 -0
  24. {git2svn-0.6.0 → git2svn-0.7.0}/.githooks/pre-commit +0 -0
  25. {git2svn-0.6.0 → git2svn-0.7.0}/.github/dependabot.yml +0 -0
  26. {git2svn-0.6.0 → git2svn-0.7.0}/.github/workflows/ci.yml +0 -0
  27. {git2svn-0.6.0 → git2svn-0.7.0}/.github/workflows/release.yml +0 -0
  28. {git2svn-0.6.0 → git2svn-0.7.0}/.gitignore +0 -0
  29. {git2svn-0.6.0 → git2svn-0.7.0}/.pre-commit-config.yaml +0 -0
  30. {git2svn-0.6.0 → git2svn-0.7.0}/CONTRIBUTING.md +0 -0
  31. {git2svn-0.6.0 → git2svn-0.7.0}/LICENSE.md +0 -0
  32. {git2svn-0.6.0 → git2svn-0.7.0}/README.md +0 -0
  33. {git2svn-0.6.0 → git2svn-0.7.0}/docs/arc42/concepts.md +0 -0
  34. {git2svn-0.6.0 → git2svn-0.7.0}/docs/req42.md +0 -0
  35. {git2svn-0.6.0 → git2svn-0.7.0}/docs/user-guide/README.md +0 -0
  36. {git2svn-0.6.0 → git2svn-0.7.0}/docs/user-guide/commands.md +0 -0
  37. {git2svn-0.6.0 → git2svn-0.7.0}/docs/user-guide/integration-workflow.md +0 -0
  38. {git2svn-0.6.0 → git2svn-0.7.0}/docs/user-guide/troubleshooting.md +0 -0
  39. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/__main__.py +0 -0
  40. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/eol.py +0 -0
  41. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/git.py +0 -0
  42. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/patcher.py +0 -0
  43. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/py.typed +0 -0
  44. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/state.py +0 -0
  45. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/svn.py +0 -0
  46. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/dependency_links.txt +0 -0
  47. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/entry_points.txt +0 -0
  48. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/requires.txt +0 -0
  49. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/top_level.txt +0 -0
  50. {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.py +0 -0
  51. {git2svn-0.6.0 → git2svn-0.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git2svn
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -79,6 +79,9 @@ classDiagram
79
79
  +main()
80
80
  +build_parser()
81
81
  }
82
+ class Setup {
83
+ +run_setup()
84
+ }
82
85
  class Synchronizer {
83
86
  +stage()
84
87
  +stage_snapshot()
@@ -86,6 +89,10 @@ classDiagram
86
89
  +replay_continue()
87
90
  +replay_abort()
88
91
  +replay_skip()
92
+ +status()
93
+ }
94
+ class StatusReporter {
95
+ +report()
89
96
  }
90
97
  class GitRepo {
91
98
  +get_diff()
@@ -103,6 +110,12 @@ classDiagram
103
110
  class Patcher {
104
111
  +apply_diff()
105
112
  }
113
+ class TerminalColor {
114
+ +bold_cyan()
115
+ +bold_green()
116
+ +bold_yellow()
117
+ +bold_red()
118
+ }
106
119
  class EOL {
107
120
  +detect_file_eol()
108
121
  +normalize_file_eol()
@@ -116,19 +129,28 @@ classDiagram
116
129
  }
117
130
 
118
131
  CLI --> Synchronizer
132
+ CLI --> Setup
133
+ Synchronizer --> StatusReporter
119
134
  Synchronizer --> GitRepo
120
135
  Synchronizer --> SvnWorkspace
121
136
  Synchronizer --> Patcher
122
137
  Synchronizer --> EOL
123
138
  Synchronizer --> State
139
+ StatusReporter --> TerminalColor
140
+ StatusReporter --> GitRepo
141
+ StatusReporter --> SvnWorkspace
142
+ StatusReporter --> State
124
143
  ```
125
144
 
126
145
  ### 5.2 Level 2: Component Responsibilities
127
146
 
128
147
  | Component | File | Responsibility |
129
148
  | :--- | :--- | :--- |
130
- | **CLI Controller** | `git2svn/cli.py` | Argument parsing, help output, pre-flight target banner, command dispatch, and exit codes. |
149
+ | **CLI Controller** | `git2svn/cli.py` | Argument parsing, help output, command dispatch, and exit code handling. |
150
+ | **Setup Service** | `git2svn/setup.py` | Automated tracking branch detection, config discovery, and Git productivity alias registration. |
131
151
  | **Synchronizer Service** | `git2svn/core.py` | Orchestrates diff application, object extraction, EOL normalization, replay loop, and conflict lifecycle. |
152
+ | **Status Reporter** | `git2svn/status.py` | Diagnostic inspection of Git, SVN working copy, conflict state, and pending commit queues. |
153
+ | **Color Terminal** | `git2svn/colors.py` | ANSI styling, auto/always/never detection, and `NO_COLOR` standard compliance. |
132
154
  | **Git Adapter** | `git2svn/git.py` | Encapsulates `git` subprocess executions, ref parsing, name-status parsing, and object extraction. |
133
155
  | **SVN Adapter** | `git2svn/svn.py` | Encapsulates `svn` subprocess executions (`add`, `rm`, `commit`, `revert`, `info`, `status`). |
134
156
  | **Patcher** | `git2svn/patcher.py` | Encapsulates `git apply` with whitespace tolerance and reject file creation. |
@@ -226,4 +248,4 @@ Detailed architectural concepts are documented in **[Cross-Cutting Concepts](con
226
248
 
227
249
  ## 9. Architecture Decisions (ADRs)
228
250
 
229
- Architecture Decision Records (ADR-1 through ADR-10) are documented in **[Architecture Decision Records](adrs.md)**.
251
+ Architecture Decision Records (ADR-1 through ADR-12) are documented in **[Architecture Decision Records](adrs.md)**.
@@ -85,3 +85,18 @@ This document contains the Architecture Decision Records (ADRs) for `git2svn`.
85
85
  - Avoids temporal inconsistencies in SVN repositories where revisions must strictly increase in time.
86
86
  - Keeps the codebase lightweight and free of brittle revision-property manipulation logic.
87
87
 
88
+ ---
89
+
90
+ ### ADR-12: Scope Boundary: Adapter Bridge vs. Autonomous Master-Slave Exporter
91
+ * **Context:** In scenarios where Git is the sole upstream source of truth without an automated reverse `svn2git` mirror creating tracking branches (e.g. `origin/svn-mirror/trunk`), `git2svn` has no external reference point to determine which Git commit corresponds to the current state of the SVN working copy. To operate autonomously without an explicit Git range or external mirror, `git2svn` would need to record and track synchronization state across boundaries (e.g. via SVN commit trailers, custom root revision properties, or local/remote sync tags), implement out-of-band conflict reconciliation logic, and handle multi-branch topology mapping.
92
+ * **Decision:** Explicitly maintain `git2svn`'s scope as a lightweight, stateless **adapter bridge** rather than an autonomous master-slave repository replication system:
93
+ 1. `git2svn` will not take on the responsibility of an internal state machine or persistent cross-VCS metadata synchronization engine.
94
+ 2. In the standard workflow, state and collision detection remain externalized in Git (via mirror tracking branches) and SVN working copy revisions.
95
+ 3. When operating without an SVN-to-Git mirror, `git2svn` continues to function reliably as an explicit delivery tool via user-directed arguments:
96
+ - `git2svn replay <base-commit>..<target-commit>` (explicitly providing the base commit where SVN last left off).
97
+ - `git2svn stage --snapshot <target-ref>` (statelessly projecting the desired Git tree onto the SVN workspace without requiring historical ancestry).
98
+ * **Consequences:**
99
+ - Preserves Unix philosophy: `git2svn` focuses solely on reliably applying Git diffs and tree states to an SVN working copy.
100
+ - Keeps the codebase lean, robust, and free of fragile metadata synchronization or repository reconciliation logic.
101
+ - Avoids architectural divergence and false promises regarding bidirectional synchronization or unmanaged out-of-band SVN mutations.
102
+
@@ -6,11 +6,12 @@ to a local Subversion (SVN) working copy.
6
6
  from __future__ import annotations
7
7
 
8
8
  from .cli import build_parser, main, parse_cli_args
9
- from .colors import TerminalColor
9
+ from .colors import ColoredLogFormatter, TerminalColor
10
10
  from .core import Synchronizer
11
11
  from .eol import detect_file_eol, normalize_file_eol
12
12
  from .git import FileChange, GitRepo, parse_name_status, parse_name_status_z, parse_ref_arguments
13
13
  from .patcher import Patcher
14
+ from .setup import run_setup
14
15
  from .state import (
15
16
  clean_conflict_artifacts,
16
17
  clear_replay_state,
@@ -18,6 +19,7 @@ from .state import (
18
19
  load_replay_state,
19
20
  save_replay_state,
20
21
  )
22
+ from .status import StatusReporter
21
23
  from .svn import SvnError, SvnLockError, SvnOutOfDateError, SvnWorkspace
22
24
 
23
25
  try:
@@ -45,6 +47,9 @@ __all__ = [
45
47
  "SvnOutOfDateError",
46
48
  "Patcher",
47
49
  "TerminalColor",
50
+ "ColoredLogFormatter",
51
+ "StatusReporter",
52
+ "run_setup",
48
53
  "FileChange",
49
54
  "parse_ref_arguments",
50
55
  "parse_name_status",
@@ -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.6.0'
22
- __version_tuple__ = version_tuple = (0, 6, 0)
21
+ __version__ = version = '0.7.0'
22
+ __version_tuple__ = version_tuple = (0, 7, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -8,6 +8,7 @@ import sys
8
8
  from pathlib import Path
9
9
  from typing import List, Optional
10
10
 
11
+ from .colors import ColoredLogFormatter, TerminalColor
11
12
  from .core import Synchronizer
12
13
  from .git import GitRepo
13
14
  from .patcher import Patcher
@@ -229,126 +230,9 @@ def build_parser() -> argparse.ArgumentParser:
229
230
 
230
231
  def handle_setup(git_repo: GitRepo, svn_dir_path: Optional[Path | str]) -> int:
231
232
  """Automate repository configuration, branch detection, and productivity aliases."""
232
- if not git_repo.is_valid_repo():
233
- print(f"Error: '{git_repo.repo_dir}' is not a valid Git repository.", file=sys.stderr)
234
- return 1
235
-
236
- # 1. Resolve SVN working copy path
237
- resolved_svn: Optional[Path] = None
238
- if svn_dir_path:
239
- resolved_svn = Path(svn_dir_path).resolve()
240
- else:
241
- # Check existing config or environment variable
242
- cfg_svn = git_repo.get_config("git2svn.svnDir")
243
- if cfg_svn:
244
- resolved_svn = Path(cfg_svn).resolve()
245
- elif "SVN_DIR" in os.environ:
246
- resolved_svn = Path(os.environ["SVN_DIR"]).resolve()
247
-
248
- if not resolved_svn:
249
- # Interactive prompt if stdin is a tty, otherwise display error
250
- if sys.stdin.isatty():
251
- try:
252
- entered = input("Path to SVN working copy: ").strip()
253
- if entered:
254
- resolved_svn = Path(entered).resolve()
255
- except (EOFError, KeyboardInterrupt):
256
- print("", file=sys.stderr)
257
- return 1
258
-
259
- if not resolved_svn:
260
- print(
261
- "Error: SVN working copy path must be provided: 'git2svn setup <path/to/svn>'.",
262
- file=sys.stderr,
263
- )
264
- return 1
233
+ from .setup import run_setup
265
234
 
266
- if not (resolved_svn / ".svn").exists():
267
- print(
268
- f"Error: '{resolved_svn}' does not appear to be an SVN working copy (no .svn found).",
269
- file=sys.stderr,
270
- )
271
- return 1
272
-
273
- # 2. Auto-detect local trunk branch
274
- local_branches = git_repo.get_local_branches()
275
- detected_trunk: Optional[str] = None
276
- if "trunk" in local_branches:
277
- detected_trunk = "trunk"
278
- elif "main" in local_branches:
279
- detected_trunk = "main"
280
- elif "master" in local_branches:
281
- detected_trunk = "master"
282
- else:
283
- # Use current branch if available
284
- current = git_repo.get_current_branch()
285
- if current and current in local_branches:
286
- detected_trunk = current
287
- elif local_branches:
288
- detected_trunk = local_branches[0]
289
-
290
- # 3. Auto-detect remote tracking mirror branch
291
- remote_branches = git_repo.get_remote_branches()
292
- detected_mirror: Optional[str] = None
293
- preferred_remotes = ["svn-mirror/trunk", "origin/trunk", "svn/trunk", "mirror/trunk"]
294
- for pref in preferred_remotes:
295
- if pref in remote_branches:
296
- detected_mirror = pref
297
- break
298
-
299
- if not detected_mirror and detected_trunk:
300
- # Check for remote branch matching <remote>/<detected_trunk>
301
- candidates = [b for b in remote_branches if b.endswith(f"/{detected_trunk}")]
302
- if candidates:
303
- # Prefer svn-mirror if present
304
- svn_cand = [c for c in candidates if "svn" in c.lower() or "mirror" in c.lower()]
305
- detected_mirror = svn_cand[0] if svn_cand else candidates[0]
306
-
307
- # 4. Apply Git configuration
308
- # SVN directory path (use forward slashes for cross-platform consistency in git config)
309
- svn_dir_str = str(resolved_svn).replace("\\", "/")
310
- git_repo.set_config("git2svn.svnDir", svn_dir_str)
311
- git_repo.set_config("pull.ff", "only")
312
-
313
- default_range: Optional[str] = None
314
- if detected_mirror and detected_trunk:
315
- default_range = f"{detected_mirror}..{detected_trunk}"
316
- git_repo.set_config("git2svn.defaultRange", default_range)
317
-
318
- # 5. Configure productivity aliases
319
- mirror_remote = detected_mirror.split("/")[0] if detected_mirror else "svn-mirror"
320
- mirror_branch = detected_mirror or f"{mirror_remote}/trunk"
321
- trunk_name = detected_trunk or "trunk"
322
-
323
- push_script = (
324
- f"!f() {{ git2svn replay && git fetch {mirror_remote} && git checkout {trunk_name} && "
325
- f"if git diff --quiet {trunk_name} {mirror_branch}; then "
326
- f"git reset --hard {mirror_branch}; "
327
- f"else echo '[git svn-push] Warning: {trunk_name} differs from {mirror_branch}. Not resetting.' >&2; fi; }}; f"
328
- )
329
- pull_script = (
330
- f"!f() {{ git fetch {mirror_remote} && git checkout {trunk_name} && "
331
- f"if ! git merge --ff-only {mirror_branch} 2>/dev/null; then "
332
- f"echo '[git svn-pull] Fast-forward not possible (local commits on {trunk_name}). Rebasing onto {mirror_branch}...'; "
333
- f"git rebase {mirror_branch}; fi; }}; f"
334
- )
335
- status_script = "!git2svn status"
336
-
337
- git_repo.set_config("alias.svn-push", push_script)
338
- git_repo.set_config("alias.svn-pull", pull_script)
339
- git_repo.set_config("alias.svn-status", status_script)
340
-
341
- print("Successfully configured git2svn:")
342
- print(f" git2svn.svnDir = {svn_dir_str}")
343
- if default_range:
344
- print(f" git2svn.defaultRange= {default_range}")
345
- else:
346
- print(" git2svn.defaultRange= (not set; could not detect mirror branch)")
347
- print(" pull.ff = only")
348
- print(f" alias.svn-push = {push_script}")
349
- print(f" alias.svn-pull = {pull_script}")
350
- print(f" alias.svn-status = {status_script}")
351
- return 0
235
+ return run_setup(git_repo, svn_dir_path)
352
236
 
353
237
 
354
238
  def parse_cli_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
@@ -374,9 +258,6 @@ def parse_cli_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
374
258
  def main(argv: Optional[List[str]] = None) -> int:
375
259
  args = parse_cli_args(argv)
376
260
 
377
- log_level = logging.DEBUG if args.verbose else logging.INFO
378
- logging.basicConfig(level=log_level, format="[%(levelname)s] %(message)s")
379
-
380
261
  git_dir = args.git_dir
381
262
  if not git_dir and args.command == "replay" and getattr(args, "replay_action", None):
382
263
  cwd = Path.cwd()
@@ -386,6 +267,20 @@ def main(argv: Optional[List[str]] = None) -> int:
386
267
 
387
268
  git_dir = git_dir or find_default_git_dir()
388
269
  git_repo = GitRepo(git_dir)
270
+
271
+ # Color mode: CLI --color -> git config git2svn.color -> "auto"
272
+ color_mode = getattr(args, "color", None)
273
+ if not color_mode:
274
+ config_color = git_repo.get_config("git2svn.color") if git_repo.is_valid_repo() else None
275
+ color_mode = config_color if config_color in ("auto", "always", "never") else "auto"
276
+
277
+ log_level = logging.DEBUG if args.verbose else logging.INFO
278
+ handler = logging.StreamHandler()
279
+ handler.setFormatter(ColoredLogFormatter(TerminalColor(color_mode)))
280
+ root_logger = logging.getLogger()
281
+ root_logger.setLevel(log_level)
282
+ root_logger.handlers = [handler]
283
+
389
284
  if not git_repo.is_valid_repo():
390
285
  print(f"Error: '{git_dir}' is not a valid Git repository.", file=sys.stderr)
391
286
  return 1
@@ -441,12 +336,6 @@ def main(argv: Optional[List[str]] = None) -> int:
441
336
  print(f"Error: '{svn_dir}' does not appear to be an SVN working copy (no .svn found).", file=sys.stderr)
442
337
  return 1
443
338
 
444
- # Color mode: CLI --color -> git config git2svn.color -> "auto"
445
- color_mode = getattr(args, "color", None)
446
- if not color_mode:
447
- config_color = git_repo.get_config("git2svn.color")
448
- color_mode = config_color if config_color in ("auto", "always", "never") else "auto"
449
-
450
339
  patcher = Patcher(svn_dir, dry_run=dry_run)
451
340
  sync_mgr = Synchronizer(
452
341
  git_repo,
@@ -473,6 +362,11 @@ def main(argv: Optional[List[str]] = None) -> int:
473
362
 
474
363
  try:
475
364
  if args.command == "stage":
365
+ is_snapshot = getattr(args, "snapshot", False)
366
+ if not ref1 and is_snapshot:
367
+ ref1 = "HEAD"
368
+ logger.info("No ref specified for snapshot stage; defaulting to HEAD.")
369
+
476
370
  if not ref1:
477
371
  print(
478
372
  "Error: stage requires a commit or range (e.g. 'git2svn stage main..feature') "
@@ -484,7 +378,7 @@ def main(argv: Optional[List[str]] = None) -> int:
484
378
  ref1,
485
379
  ref2,
486
380
  use_copy=use_copy,
487
- snapshot=getattr(args, "snapshot", False),
381
+ snapshot=is_snapshot,
488
382
  )
489
383
  elif args.command == "replay":
490
384
  action = getattr(args, "replay_action", None)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import os
4
5
  import sys
5
6
 
@@ -90,7 +91,7 @@ class TerminalColor:
90
91
  def bold_cyan(self, text: str) -> str:
91
92
  return self.colorize(text, self.BOLD_CYAN)
92
93
 
93
- # Semantic badges
94
+ # Semantic badges & prefixes
94
95
  def ok(self, text: str = "OK") -> str:
95
96
  return self.bold_green(f"✔ {text}")
96
97
 
@@ -102,3 +103,45 @@ class TerminalColor:
102
103
 
103
104
  def error(self, text: str = "Error") -> str:
104
105
  return self.bold_red(f"✖ {text}")
106
+
107
+ def info_badge(self, text: str = "[INFO]") -> str:
108
+ return self.bold_cyan(text)
109
+
110
+ def target_badge(self, text: str = "[TARGET]") -> str:
111
+ return self.bold_cyan(text)
112
+
113
+ def warn_badge(self, text: str = "[WARN]") -> str:
114
+ return self.bold_yellow(text)
115
+
116
+ def error_badge(self, text: str = "[ERROR]") -> str:
117
+ return self.bold_red(text)
118
+
119
+ def paused_badge(self, text: str = "[PAUSED]") -> str:
120
+ return self.bold_yellow(text)
121
+
122
+
123
+ class ColoredLogFormatter(logging.Formatter):
124
+ """Custom logging formatter that colorizes log level prefixes using TerminalColor."""
125
+
126
+ def __init__(self, color: TerminalColor):
127
+ super().__init__()
128
+ self.color = color
129
+
130
+ def format(self, record: logging.LogRecord) -> str:
131
+ levelname = record.levelname
132
+ msg = record.getMessage()
133
+ if not self.color.enabled:
134
+ return f"[{levelname}] {msg}"
135
+
136
+ if levelname == "INFO":
137
+ badge = self.color.info_badge("[INFO]")
138
+ elif levelname in ("WARNING", "WARN"):
139
+ badge = self.color.warn_badge(f"[{levelname}]")
140
+ elif levelname in ("ERROR", "CRITICAL"):
141
+ badge = self.color.error_badge(f"[{levelname}]")
142
+ elif levelname == "DEBUG":
143
+ badge = self.color.dim(f"[{levelname}]")
144
+ else:
145
+ badge = f"[{levelname}]"
146
+
147
+ return f"{badge} {msg}"
@@ -52,9 +52,10 @@ class Synchronizer:
52
52
  svn_rev = svn_info.get("Revision")
53
53
  svn_suffix = f" (r{svn_rev})" if svn_rev else ""
54
54
 
55
+ target_badge = self.color.target_badge("[TARGET]")
55
56
  banner = (
56
- f"[TARGET] Git source : {self.git.repo_dir.name} [{branch} @ {head}] -> ref: {target_ref_spec}\n"
57
- f"[TARGET] SVN target : {self.svn.workspace_dir.name} [{svn_target}{svn_suffix}]"
57
+ f"{target_badge} Git source : {self.git.repo_dir.name} [{branch} @ {head}] -> ref: {target_ref_spec}\n"
58
+ f"{target_badge} SVN target : {self.svn.workspace_dir.name} [{svn_target}{svn_suffix}]"
58
59
  )
59
60
  print(banner)
60
61
 
@@ -71,7 +72,10 @@ class Synchronizer:
71
72
  If use_copy=True, brute-force copies files using Git object DB (bypassing patch).
72
73
  """
73
74
  if snapshot:
74
- self.stage_snapshot(ref1)
75
+ # If a range was supplied (e.g. origin/main..main or ref1 and ref2), snapshot targets the end ref
76
+ is_single, start_or_commit, end_ref = parse_ref_arguments(ref1, ref2)
77
+ target_ref = end_ref if not is_single and end_ref else start_or_commit
78
+ self.stage_snapshot(target_ref)
75
79
  return
76
80
 
77
81
  is_single, start_or_commit, end_ref = parse_ref_arguments(ref1, ref2)
@@ -381,8 +385,9 @@ class Synchronizer:
381
385
  rej_rel = [str(r.relative_to(self.svn.workspace_dir)) for r in rej_files]
382
386
  rej_info = "\nConflicts detected in:\n" + "\n".join(f" - {f}" for f in rej_rel)
383
387
 
388
+ paused_badge = self.color.paused_badge("[PAUSED]")
384
389
  print(
385
- f'\n[PAUSED] Conflict while applying commit {commit_hash[:8]} ({idx}/{total_commits}): "{first_line}"'
390
+ f'\n{paused_badge} Conflict while applying commit {commit_hash[:8]} ({idx}/{total_commits}): "{first_line}"'
386
391
  f"{rej_info}\n\n"
387
392
  f"To resolve:\n"
388
393
  f" 1. Resolve conflicts in '{self.svn.workspace_dir}' and stage changes ('svn add' / 'svn rm').\n"
@@ -524,127 +529,6 @@ class Synchronizer:
524
529
  0 if clean and ready / in sync
525
530
  1 if errors, working copy locked, merge conflicts, or linear violations exist
526
531
  """
527
- has_error = False
528
- c = self.color
532
+ from .status import StatusReporter
529
533
 
530
- # 1. Check in-progress replay state
531
- state = load_replay_state(self.svn.workspace_dir)
532
- if state:
533
- has_error = True
534
- current_commit = state.get("current_commit", "unknown")
535
- current_msg = state.get("current_commit_msg", "")
536
- first_msg = current_msg.splitlines()[0] if current_msg else ""
537
- remaining = state.get("remaining_commits", [])
538
- completed = state.get("completed_commits", 0)
539
- total = state.get("total_commits", len(remaining) + 1)
540
-
541
- print(c.bold_red("[Replay In Progress]"))
542
- print(
543
- f' State : {c.bold_red("PAUSED")} (conflict at commit {c.yellow(current_commit[:8])} "{first_msg}")'
544
- )
545
- print(f" Progress : {completed} of {total} commits applied ({len(remaining)} remaining)")
546
-
547
- rej_files = find_conflict_artifacts(self.svn.workspace_dir)
548
- if rej_files:
549
- rel_rejs = [str(r.relative_to(self.svn.workspace_dir)) for r in rej_files]
550
- print(f" Conflicts : {c.bold_red(', '.join(rel_rejs))}")
551
- action_text = (
552
- f"Resolve conflicts and run {c.bold_cyan('git2svn replay --continue')} (or '--abort' / '--skip')"
553
- )
554
- print(f" Action : {action_text}\n")
555
-
556
- # 2. Git Workspace Status
557
- git_clean = self.git.is_clean()
558
- branch = self.git.get_current_branch()
559
- head_commit = self.git.get_head_commit()
560
- head_subject = self.git.get_head_subject()
561
- head_desc = f'{c.yellow(head_commit)} "{head_subject}"' if head_subject else c.yellow(head_commit)
562
-
563
- print(c.bold_cyan("[Git Workspace]"))
564
- print(f" Repository: {self.git.repo_dir}")
565
- print(f" Branch : {c.bold(branch)} (at {head_desc})")
566
- if git_clean:
567
- print(f" Tree : {c.bold_green('Clean')}")
568
- else:
569
- print(f" Tree : {c.bold_yellow('Dirty')} (uncommitted changes present)")
570
-
571
- # 3. SVN Working Copy Status
572
- print(f"\n{c.bold_cyan('[SVN Working Copy]')}")
573
- print(f" Path : {self.svn.workspace_dir}")
574
- svn_info = self.svn.get_info()
575
- svn_url = svn_info.get("URL") or svn_info.get("Relative URL") or "unknown"
576
- svn_rev = svn_info.get("Revision")
577
- rev_str = f" ({c.dim(f'r{svn_rev}')})" if svn_rev else ""
578
- print(f" Target : {svn_url}{rev_str}")
579
-
580
- try:
581
- svn_clean = self.svn.is_clean()
582
- if svn_clean:
583
- print(f" Tree : {c.bold_green('Clean')} (no uncommitted changes, unlocked)")
584
- else:
585
- uncommitted = self.svn.get_status_summary()
586
- print(f" Tree : {c.bold_yellow(f'Dirty ({len(uncommitted)} uncommitted changes)')}")
587
- for item in uncommitted[:5]:
588
- print(f" {item}")
589
- if len(uncommitted) > 5:
590
- print(f" {c.dim(f'... and {len(uncommitted) - 5} more')}")
591
- except SvnError as e:
592
- has_error = True
593
- print(f" Tree : {c.bold_red(f'Error: {e.args[0].splitlines()[0]}')}")
594
-
595
- # 4. Synchronization Queue & Default Range
596
- default_range = self.git.get_config("git2svn.defaultRange")
597
- print(f"\n{c.bold_cyan('[Synchronization]')}")
598
- if not default_range:
599
- print(
600
- f" Range : {c.dim('Not configured')} (run 'git2svn setup' or 'git config git2svn.defaultRange <range>')"
601
- )
602
- else:
603
- print(f" Range : {c.bold(default_range)}")
604
- try:
605
- is_single, start_ref, end_ref = parse_ref_arguments(default_range)
606
- if is_single:
607
- commits = [start_ref] if self.git.ref_exists(start_ref) else []
608
- merges: List[str] = []
609
- else:
610
- assert end_ref is not None
611
- if not self.git.ref_exists(start_ref):
612
- warn_msg = f"Range start ref '{start_ref}' not found in Git."
613
- print(f" Warning : {c.bold_yellow(warn_msg)}")
614
- commits = []
615
- merges = []
616
- elif not self.git.ref_exists(end_ref):
617
- warn_msg = f"Range end ref '{end_ref}' not found in Git."
618
- print(f" Warning : {c.bold_yellow(warn_msg)}")
619
- commits = []
620
- merges = []
621
- else:
622
- merges = self.git.get_merge_commits(start_ref, end_ref)
623
- commits = self.git.get_commit_range(start_ref, end_ref)
624
-
625
- if merges:
626
- has_error = True
627
- inv_msg = f"INVALID ({len(merges)} merge commits found in range - linear rebase required)"
628
- print(f" Linearity : {c.bold_red(inv_msg)}")
629
- else:
630
- print(f" Linearity : {c.bold_green('OK')} (strictly linear)")
631
-
632
- if not commits:
633
- print(f" Pending : {c.bold_green('In sync')} (0 commits to replay)")
634
- else:
635
- pending_title = f"{len(commits)} commit(s) ready to replay:"
636
- print(f" Pending : {c.bold_yellow(pending_title)}")
637
- for idx, c_hash in enumerate(commits[:10], start=1):
638
- msg = self.git.get_commit_message(c_hash)
639
- subject = msg.splitlines()[0] if msg else ""
640
- print(f" {c.dim(f'{idx}.')} [{c.yellow(c_hash[:8])}] {subject}")
641
- if len(commits) > 10:
642
- print(f" {c.dim(f'... and {len(commits) - 10} more')}")
643
- except Exception as e:
644
- err_msg = f"Could not parse range '{default_range}': {e}"
645
- print(f" Error : {c.bold_red(err_msg)}")
646
- has_error = True
647
-
648
- if has_error:
649
- return 1
650
- return 0
534
+ return StatusReporter(self.git, self.svn, self.color).report()