git2svn 0.5.2__tar.gz → 0.6.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 (42) hide show
  1. {git2svn-0.5.2/git2svn.egg-info → git2svn-0.6.1}/PKG-INFO +1 -1
  2. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/__init__.py +2 -0
  3. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/_version.py +2 -2
  4. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/cli.py +26 -2
  5. git2svn-0.6.1/git2svn/colors.py +104 -0
  6. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/core.py +49 -29
  7. {git2svn-0.5.2 → git2svn-0.6.1/git2svn.egg-info}/PKG-INFO +1 -1
  8. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/SOURCES.txt +1 -0
  9. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/scm_file_list.json +1 -0
  10. {git2svn-0.5.2 → git2svn-0.6.1}/pyproject.toml +1 -1
  11. {git2svn-0.5.2 → git2svn-0.6.1}/tests/test_e2e.py +21 -0
  12. {git2svn-0.5.2 → git2svn-0.6.1}/tests/test_git2svn.py +53 -1
  13. {git2svn-0.5.2 → git2svn-0.6.1}/.githooks/pre-commit +0 -0
  14. {git2svn-0.5.2 → git2svn-0.6.1}/.github/dependabot.yml +0 -0
  15. {git2svn-0.5.2 → git2svn-0.6.1}/.github/workflows/ci.yml +0 -0
  16. {git2svn-0.5.2 → git2svn-0.6.1}/.github/workflows/release.yml +0 -0
  17. {git2svn-0.5.2 → git2svn-0.6.1}/.gitignore +0 -0
  18. {git2svn-0.5.2 → git2svn-0.6.1}/.pre-commit-config.yaml +0 -0
  19. {git2svn-0.5.2 → git2svn-0.6.1}/CONTRIBUTING.md +0 -0
  20. {git2svn-0.5.2 → git2svn-0.6.1}/LICENSE.md +0 -0
  21. {git2svn-0.5.2 → git2svn-0.6.1}/README.md +0 -0
  22. {git2svn-0.5.2 → git2svn-0.6.1}/docs/arc42/README.md +0 -0
  23. {git2svn-0.5.2 → git2svn-0.6.1}/docs/arc42/adrs.md +0 -0
  24. {git2svn-0.5.2 → git2svn-0.6.1}/docs/arc42/concepts.md +0 -0
  25. {git2svn-0.5.2 → git2svn-0.6.1}/docs/req42.md +0 -0
  26. {git2svn-0.5.2 → git2svn-0.6.1}/docs/user-guide/README.md +0 -0
  27. {git2svn-0.5.2 → git2svn-0.6.1}/docs/user-guide/commands.md +0 -0
  28. {git2svn-0.5.2 → git2svn-0.6.1}/docs/user-guide/integration-workflow.md +0 -0
  29. {git2svn-0.5.2 → git2svn-0.6.1}/docs/user-guide/troubleshooting.md +0 -0
  30. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/__main__.py +0 -0
  31. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/eol.py +0 -0
  32. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/git.py +0 -0
  33. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/patcher.py +0 -0
  34. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/py.typed +0 -0
  35. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/state.py +0 -0
  36. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/svn.py +0 -0
  37. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/dependency_links.txt +0 -0
  38. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/entry_points.txt +0 -0
  39. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/requires.txt +0 -0
  40. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/top_level.txt +0 -0
  41. {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.py +0 -0
  42. {git2svn-0.5.2 → git2svn-0.6.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git2svn
3
- Version: 0.5.2
3
+ Version: 0.6.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
@@ -6,6 +6,7 @@ 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
10
  from .core import Synchronizer
10
11
  from .eol import detect_file_eol, normalize_file_eol
11
12
  from .git import FileChange, GitRepo, parse_name_status, parse_name_status_z, parse_ref_arguments
@@ -43,6 +44,7 @@ __all__ = [
43
44
  "SvnLockError",
44
45
  "SvnOutOfDateError",
45
46
  "Patcher",
47
+ "TerminalColor",
46
48
  "FileChange",
47
49
  "parse_ref_arguments",
48
50
  "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.5.2'
22
- __version_tuple__ = version_tuple = (0, 5, 2)
21
+ __version__ = version = '0.6.1'
22
+ __version_tuple__ = version_tuple = (0, 6, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -67,6 +67,12 @@ def build_parser() -> argparse.ArgumentParser:
67
67
  default=argparse.SUPPRESS,
68
68
  help="Enable verbose output",
69
69
  )
70
+ common_parser.add_argument(
71
+ "--color",
72
+ choices=["auto", "always", "never"],
73
+ default=argparse.SUPPRESS,
74
+ help="Control colored output (auto, always, never; default: git config git2svn.color or auto)",
75
+ )
70
76
 
71
77
  parser = argparse.ArgumentParser(
72
78
  prog="git2svn",
@@ -435,8 +441,21 @@ def main(argv: Optional[List[str]] = None) -> int:
435
441
  print(f"Error: '{svn_dir}' does not appear to be an SVN working copy (no .svn found).", file=sys.stderr)
436
442
  return 1
437
443
 
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
+
438
450
  patcher = Patcher(svn_dir, dry_run=dry_run)
439
- sync_mgr = Synchronizer(git_repo, svn_workspace, patcher, dry_run=dry_run, auto_update=auto_update)
451
+ sync_mgr = Synchronizer(
452
+ git_repo,
453
+ svn_workspace,
454
+ patcher,
455
+ dry_run=dry_run,
456
+ auto_update=auto_update,
457
+ color_mode=color_mode,
458
+ )
440
459
 
441
460
  # Fallback to git2svn.defaultRange if ref1 is omitted
442
461
  ref1 = getattr(args, "ref1", None)
@@ -454,6 +473,11 @@ def main(argv: Optional[List[str]] = None) -> int:
454
473
 
455
474
  try:
456
475
  if args.command == "stage":
476
+ is_snapshot = getattr(args, "snapshot", False)
477
+ if not ref1 and is_snapshot:
478
+ ref1 = "HEAD"
479
+ logger.info("No ref specified for snapshot stage; defaulting to HEAD.")
480
+
457
481
  if not ref1:
458
482
  print(
459
483
  "Error: stage requires a commit or range (e.g. 'git2svn stage main..feature') "
@@ -465,7 +489,7 @@ def main(argv: Optional[List[str]] = None) -> int:
465
489
  ref1,
466
490
  ref2,
467
491
  use_copy=use_copy,
468
- snapshot=getattr(args, "snapshot", False),
492
+ snapshot=is_snapshot,
469
493
  )
470
494
  elif args.command == "replay":
471
495
  action = getattr(args, "replay_action", None)
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+
6
+
7
+ class TerminalColor:
8
+ """ANSI terminal color formatting with auto/always/never detection and NO_COLOR support."""
9
+
10
+ # ANSI Styles & Colors
11
+ RESET = "\033[0m"
12
+ BOLD = "\033[1m"
13
+ DIM = "\033[2m"
14
+
15
+ RED = "\033[31m"
16
+ GREEN = "\033[32m"
17
+ YELLOW = "\033[33m"
18
+ BLUE = "\033[34m"
19
+ MAGENTA = "\033[35m"
20
+ CYAN = "\033[36m"
21
+ GRAY = "\033[90m"
22
+
23
+ BOLD_RED = "\033[1;31m"
24
+ BOLD_GREEN = "\033[1;32m"
25
+ BOLD_YELLOW = "\033[1;33m"
26
+ BOLD_CYAN = "\033[1;36m"
27
+
28
+ def __init__(self, mode: str = "auto"):
29
+ self.mode = mode.lower()
30
+ self.enabled = self._resolve_enabled()
31
+
32
+ def _resolve_enabled(self) -> bool:
33
+ if self.mode == "always":
34
+ return True
35
+ if self.mode == "never":
36
+ return False
37
+
38
+ # Mode is "auto"
39
+ # 1. Respect NO_COLOR standard (https://no-color.org/)
40
+ if "NO_COLOR" in os.environ and os.environ["NO_COLOR"] != "":
41
+ return False
42
+
43
+ # 2. Check for dumb terminals
44
+ if os.environ.get("TERM") == "dumb":
45
+ return False
46
+
47
+ # 3. Check if stdout is an interactive TTY
48
+ try:
49
+ return sys.stdout.isatty()
50
+ except Exception:
51
+ return False
52
+
53
+ def colorize(self, text: str, ansi_code: str) -> str:
54
+ """Wrap text in ANSI color escape codes if coloring is enabled."""
55
+ if not self.enabled:
56
+ return text
57
+ return f"{ansi_code}{text}{self.RESET}"
58
+
59
+ # Style helpers
60
+ def bold(self, text: str) -> str:
61
+ return self.colorize(text, self.BOLD)
62
+
63
+ def dim(self, text: str) -> str:
64
+ return self.colorize(text, self.DIM)
65
+
66
+ def red(self, text: str) -> str:
67
+ return self.colorize(text, self.RED)
68
+
69
+ def green(self, text: str) -> str:
70
+ return self.colorize(text, self.GREEN)
71
+
72
+ def yellow(self, text: str) -> str:
73
+ return self.colorize(text, self.YELLOW)
74
+
75
+ def cyan(self, text: str) -> str:
76
+ return self.colorize(text, self.CYAN)
77
+
78
+ def gray(self, text: str) -> str:
79
+ return self.colorize(text, self.GRAY)
80
+
81
+ def bold_red(self, text: str) -> str:
82
+ return self.colorize(text, self.BOLD_RED)
83
+
84
+ def bold_green(self, text: str) -> str:
85
+ return self.colorize(text, self.BOLD_GREEN)
86
+
87
+ def bold_yellow(self, text: str) -> str:
88
+ return self.colorize(text, self.BOLD_YELLOW)
89
+
90
+ def bold_cyan(self, text: str) -> str:
91
+ return self.colorize(text, self.BOLD_CYAN)
92
+
93
+ # Semantic badges
94
+ def ok(self, text: str = "OK") -> str:
95
+ return self.bold_green(f"✔ {text}")
96
+
97
+ def clean(self, text: str = "Clean") -> str:
98
+ return self.bold_green(f"✔ {text}")
99
+
100
+ def warn(self, text: str = "Warning") -> str:
101
+ return self.bold_yellow(f"⚠ {text}")
102
+
103
+ def error(self, text: str = "Error") -> str:
104
+ return self.bold_red(f"✖ {text}")
@@ -7,6 +7,7 @@ import sys
7
7
  from pathlib import Path
8
8
  from typing import Dict, List, Optional
9
9
 
10
+ from .colors import TerminalColor
10
11
  from .eol import detect_file_eol, normalize_file_eol
11
12
  from .git import FileChange, GitRepo, parse_ref_arguments
12
13
  from .patcher import Patcher
@@ -32,12 +33,14 @@ class Synchronizer:
32
33
  patcher: Patcher,
33
34
  dry_run: bool = False,
34
35
  auto_update: bool = False,
36
+ color_mode: str = "auto",
35
37
  ):
36
38
  self.git = git_repo
37
39
  self.svn = svn_workspace
38
40
  self.patcher = patcher
39
41
  self.dry_run = dry_run
40
42
  self.auto_update = auto_update
43
+ self.color = TerminalColor(color_mode)
41
44
 
42
45
  def show_identity_banner(self, target_ref_spec: str) -> None:
43
46
  """Display identity banner showing active Git and SVN target branches/URLs."""
@@ -68,7 +71,10 @@ class Synchronizer:
68
71
  If use_copy=True, brute-force copies files using Git object DB (bypassing patch).
69
72
  """
70
73
  if snapshot:
71
- self.stage_snapshot(ref1)
74
+ # If a range was supplied (e.g. origin/main..main or ref1 and ref2), snapshot targets the end ref
75
+ is_single, start_or_commit, end_ref = parse_ref_arguments(ref1, ref2)
76
+ target_ref = end_ref if not is_single and end_ref else start_or_commit
77
+ self.stage_snapshot(target_ref)
72
78
  return
73
79
 
74
80
  is_single, start_or_commit, end_ref = parse_ref_arguments(ref1, ref2)
@@ -522,6 +528,7 @@ class Synchronizer:
522
528
  1 if errors, working copy locked, merge conflicts, or linear violations exist
523
529
  """
524
530
  has_error = False
531
+ c = self.color
525
532
 
526
533
  # 1. Check in-progress replay state
527
534
  state = load_replay_state(self.svn.workspace_dir)
@@ -534,59 +541,69 @@ class Synchronizer:
534
541
  completed = state.get("completed_commits", 0)
535
542
  total = state.get("total_commits", len(remaining) + 1)
536
543
 
537
- print("[Replay In Progress]")
538
- print(f' State : PAUSED (conflict at commit {current_commit[:8]} "{first_msg}")')
544
+ print(c.bold_red("[Replay In Progress]"))
545
+ print(
546
+ f' State : {c.bold_red("PAUSED")} (conflict at commit {c.yellow(current_commit[:8])} "{first_msg}")'
547
+ )
539
548
  print(f" Progress : {completed} of {total} commits applied ({len(remaining)} remaining)")
540
549
 
541
550
  rej_files = find_conflict_artifacts(self.svn.workspace_dir)
542
551
  if rej_files:
543
552
  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")
553
+ print(f" Conflicts : {c.bold_red(', '.join(rel_rejs))}")
554
+ action_text = (
555
+ f"Resolve conflicts and run {c.bold_cyan('git2svn replay --continue')} (or '--abort' / '--skip')"
556
+ )
557
+ print(f" Action : {action_text}\n")
546
558
 
547
559
  # 2. Git Workspace Status
548
560
  git_clean = self.git.is_clean()
549
561
  branch = self.git.get_current_branch()
550
562
  head_commit = self.git.get_head_commit()
551
563
  head_subject = self.git.get_head_subject()
552
- head_desc = f'{head_commit} "{head_subject}"' if head_subject else head_commit
564
+ head_desc = f'{c.yellow(head_commit)} "{head_subject}"' if head_subject else c.yellow(head_commit)
553
565
 
554
- print("[Git Workspace]")
566
+ print(c.bold_cyan("[Git Workspace]"))
555
567
  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)'}")
568
+ print(f" Branch : {c.bold(branch)} (at {head_desc})")
569
+ if git_clean:
570
+ print(f" Tree : {c.bold_green('Clean')}")
571
+ else:
572
+ print(f" Tree : {c.bold_yellow('Dirty')} (uncommitted changes present)")
558
573
 
559
574
  # 3. SVN Working Copy Status
560
- print("\n[SVN Working Copy]")
575
+ print(f"\n{c.bold_cyan('[SVN Working Copy]')}")
561
576
  print(f" Path : {self.svn.workspace_dir}")
562
577
  svn_info = self.svn.get_info()
563
578
  svn_url = svn_info.get("URL") or svn_info.get("Relative URL") or "unknown"
564
579
  svn_rev = svn_info.get("Revision")
565
- rev_str = f" (r{svn_rev})" if svn_rev else ""
580
+ rev_str = f" ({c.dim(f'r{svn_rev}')})" if svn_rev else ""
566
581
  print(f" Target : {svn_url}{rev_str}")
567
582
 
568
583
  try:
569
584
  svn_clean = self.svn.is_clean()
570
585
  if svn_clean:
571
- print(" Tree : Clean (no uncommitted changes, unlocked)")
586
+ print(f" Tree : {c.bold_green('Clean')} (no uncommitted changes, unlocked)")
572
587
  else:
573
588
  uncommitted = self.svn.get_status_summary()
574
- print(f" Tree : Dirty ({len(uncommitted)} uncommitted changes)")
589
+ print(f" Tree : {c.bold_yellow(f'Dirty ({len(uncommitted)} uncommitted changes)')}")
575
590
  for item in uncommitted[:5]:
576
591
  print(f" {item}")
577
592
  if len(uncommitted) > 5:
578
- print(f" ... and {len(uncommitted) - 5} more")
593
+ print(f" {c.dim(f'... and {len(uncommitted) - 5} more')}")
579
594
  except SvnError as e:
580
595
  has_error = True
581
- print(f" Tree : Error: {e.args[0].splitlines()[0]}")
596
+ print(f" Tree : {c.bold_red(f'Error: {e.args[0].splitlines()[0]}')}")
582
597
 
583
598
  # 4. Synchronization Queue & Default Range
584
599
  default_range = self.git.get_config("git2svn.defaultRange")
585
- print("\n[Synchronization]")
600
+ print(f"\n{c.bold_cyan('[Synchronization]')}")
586
601
  if not default_range:
587
- print(" Range : Not configured (run 'git2svn setup' or 'git config git2svn.defaultRange <range>')")
602
+ print(
603
+ f" Range : {c.dim('Not configured')} (run 'git2svn setup' or 'git config git2svn.defaultRange <range>')"
604
+ )
588
605
  else:
589
- print(f" Range : {default_range}")
606
+ print(f" Range : {c.bold(default_range)}")
590
607
  try:
591
608
  is_single, start_ref, end_ref = parse_ref_arguments(default_range)
592
609
  if is_single:
@@ -595,11 +612,13 @@ class Synchronizer:
595
612
  else:
596
613
  assert end_ref is not None
597
614
  if not self.git.ref_exists(start_ref):
598
- print(f" Warning : Range start ref '{start_ref}' not found in Git.")
615
+ warn_msg = f"Range start ref '{start_ref}' not found in Git."
616
+ print(f" Warning : {c.bold_yellow(warn_msg)}")
599
617
  commits = []
600
618
  merges = []
601
619
  elif not self.git.ref_exists(end_ref):
602
- print(f" Warning : Range end ref '{end_ref}' not found in Git.")
620
+ warn_msg = f"Range end ref '{end_ref}' not found in Git."
621
+ print(f" Warning : {c.bold_yellow(warn_msg)}")
603
622
  commits = []
604
623
  merges = []
605
624
  else:
@@ -608,24 +627,25 @@ class Synchronizer:
608
627
 
609
628
  if merges:
610
629
  has_error = True
611
- print(
612
- f" Linearity : INVALID ({len(merges)} merge commits found in range - linear rebase required)"
613
- )
630
+ inv_msg = f"INVALID ({len(merges)} merge commits found in range - linear rebase required)"
631
+ print(f" Linearity : {c.bold_red(inv_msg)}")
614
632
  else:
615
- print(" Linearity : OK (strictly linear)")
633
+ print(f" Linearity : {c.bold_green('OK')} (strictly linear)")
616
634
 
617
635
  if not commits:
618
- print(" Pending : In sync (0 commits to replay)")
636
+ print(f" Pending : {c.bold_green('In sync')} (0 commits to replay)")
619
637
  else:
620
- print(f" Pending : {len(commits)} commit(s) ready to replay:")
638
+ pending_title = f"{len(commits)} commit(s) ready to replay:"
639
+ print(f" Pending : {c.bold_yellow(pending_title)}")
621
640
  for idx, c_hash in enumerate(commits[:10], start=1):
622
641
  msg = self.git.get_commit_message(c_hash)
623
642
  subject = msg.splitlines()[0] if msg else ""
624
- print(f" {idx}. [{c_hash[:8]}] {subject}")
643
+ print(f" {c.dim(f'{idx}.')} [{c.yellow(c_hash[:8])}] {subject}")
625
644
  if len(commits) > 10:
626
- print(f" ... and {len(commits) - 10} more")
645
+ print(f" {c.dim(f'... and {len(commits) - 10} more')}")
627
646
  except Exception as e:
628
- print(f" Error : Could not parse range '{default_range}': {e}")
647
+ err_msg = f"Could not parse range '{default_range}': {e}"
648
+ print(f" Error : {c.bold_red(err_msg)}")
629
649
  has_error = True
630
650
 
631
651
  if has_error:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git2svn
3
- Version: 0.5.2
3
+ Version: 0.6.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
@@ -21,6 +21,7 @@ git2svn/__init__.py
21
21
  git2svn/__main__.py
22
22
  git2svn/_version.py
23
23
  git2svn/cli.py
24
+ git2svn/colors.py
24
25
  git2svn/core.py
25
26
  git2svn/eol.py
26
27
  git2svn/git.py
@@ -21,6 +21,7 @@
21
21
  "git2svn/__init__.py",
22
22
  "git2svn/__main__.py",
23
23
  "git2svn/cli.py",
24
+ "git2svn/colors.py",
24
25
  "git2svn/core.py",
25
26
  "git2svn/eol.py",
26
27
  "git2svn/git.py",
@@ -98,7 +98,7 @@ disallow_incomplete_defs = true
98
98
  check_untyped_defs = true
99
99
  no_implicit_optional = true
100
100
  warn_redundant_casts = true
101
- warn_unused_ignores = true
101
+ warn_unused_ignores = false
102
102
  warn_return_any = true
103
103
  show_error_codes = true
104
104
 
@@ -233,6 +233,27 @@ class TestGit2SvnE2E(unittest.TestCase):
233
233
  self.assertIn("D obsolete.txt", status)
234
234
  self.assertIn("A brand_new.txt", status)
235
235
 
236
+ def test_e2e_snapshot_with_default_range(self):
237
+ """Test snapshot stage correctly resolves target ref when defaultRange is a range (e.g. origin/main..main)."""
238
+ # Create base file in SVN
239
+ (self.svn_wc_dir / "base.txt").write_text("v1\n", encoding="utf-8")
240
+ subprocess.run([SVN_BIN, "add", "base.txt"], cwd=self.svn_wc_dir, check=True)
241
+ subprocess.run([SVN_BIN, "commit", "-m", "init"], cwd=self.svn_wc_dir, check=True)
242
+
243
+ # In Git, create base commit and feature commit
244
+ (self.git_dir / "base.txt").write_text("v1\n", encoding="utf-8")
245
+ c1 = self._git_commit("feat: c1")
246
+ (self.git_dir / "base.txt").write_text("v2\n", encoding="utf-8")
247
+ c2 = self._git_commit("feat: c2")
248
+
249
+ # Configure defaultRange as a range c1..c2
250
+ subprocess.run([GIT_BIN, "config", "git2svn.defaultRange", f"{c1}..{c2}"], cwd=self.git_dir, check=True)
251
+
252
+ # Run stage --snapshot without any ref (should use end ref of defaultRange: c2)
253
+ exit_code = cli_main(["stage", "--snapshot", "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
254
+ self.assertEqual(exit_code, 0)
255
+ self.assertEqual((self.svn_wc_dir / "base.txt").read_text(encoding="utf-8"), "v2\n")
256
+
236
257
  def test_e2e_replay_conflict_pause_and_continue(self):
237
258
  """Test replay pausing on a patch conflict in a real SVN WC and resuming with --continue."""
238
259
  # 1. Base commit in Git and SVN
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env python3
1
+ import os
2
2
  import subprocess
3
3
  import tempfile
4
4
  import unittest
@@ -1263,5 +1263,57 @@ class TestStatus(unittest.TestCase):
1263
1263
  mock_status.assert_called_once()
1264
1264
 
1265
1265
 
1266
+ class TestTerminalColor(unittest.TestCase):
1267
+ def test_color_always(self):
1268
+ color = git2svn.TerminalColor(mode="always")
1269
+ self.assertTrue(color.enabled)
1270
+ self.assertIn("\033[", color.bold_cyan("Test"))
1271
+ self.assertIn("\033[", color.clean())
1272
+ self.assertIn("\033[", color.ok())
1273
+ self.assertIn("\033[", color.warn())
1274
+ self.assertIn("\033[", color.error())
1275
+
1276
+ def test_color_never(self):
1277
+ color = git2svn.TerminalColor(mode="never")
1278
+ self.assertFalse(color.enabled)
1279
+ self.assertEqual(color.bold_cyan("Test"), "Test")
1280
+ self.assertEqual(color.clean(), "✔ Clean")
1281
+
1282
+ def test_color_auto_no_color_env(self):
1283
+ with patch.dict(os.environ, {"NO_COLOR": "1"}):
1284
+ color = git2svn.TerminalColor(mode="auto")
1285
+ self.assertFalse(color.enabled)
1286
+ self.assertEqual(color.bold("Hello"), "Hello")
1287
+
1288
+ def test_color_auto_dumb_term(self):
1289
+ with patch.dict(os.environ, {"TERM": "dumb", "NO_COLOR": ""}):
1290
+ color = git2svn.TerminalColor(mode="auto")
1291
+ self.assertFalse(color.enabled)
1292
+
1293
+ def test_color_cli_and_git_config(self):
1294
+ temp_dir = tempfile.TemporaryDirectory()
1295
+ try:
1296
+ repo_path = Path(temp_dir.name).resolve()
1297
+ (repo_path / ".svn").mkdir()
1298
+ subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
1299
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=repo_path, check=True)
1300
+ subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo_path, check=True)
1301
+ subprocess.run(["git", "config", "git2svn.color", "always"], cwd=repo_path, check=True)
1302
+
1303
+ with patch("git2svn.cli.Synchronizer") as mock_sync_cls:
1304
+ mock_sync_cls.return_value.status.return_value = 0
1305
+ # When no --color is passed, config 'always' should be used
1306
+ git2svn.main(["--git-dir", str(repo_path), "--svn-dir", str(repo_path), "status"])
1307
+ _, kwargs = mock_sync_cls.call_args
1308
+ self.assertEqual(kwargs.get("color_mode"), "always")
1309
+
1310
+ # When CLI --color never is passed, it overrides git config
1311
+ git2svn.main(["--git-dir", str(repo_path), "--svn-dir", str(repo_path), "--color", "never", "status"])
1312
+ _, kwargs = mock_sync_cls.call_args
1313
+ self.assertEqual(kwargs.get("color_mode"), "never")
1314
+ finally:
1315
+ temp_dir.cleanup()
1316
+
1317
+
1266
1318
  if __name__ == "__main__":
1267
1319
  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