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.
- {git2svn-0.5.2/git2svn.egg-info → git2svn-0.6.1}/PKG-INFO +1 -1
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/__init__.py +2 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/_version.py +2 -2
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/cli.py +26 -2
- git2svn-0.6.1/git2svn/colors.py +104 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/core.py +49 -29
- {git2svn-0.5.2 → git2svn-0.6.1/git2svn.egg-info}/PKG-INFO +1 -1
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/SOURCES.txt +1 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/scm_file_list.json +1 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/pyproject.toml +1 -1
- {git2svn-0.5.2 → git2svn-0.6.1}/tests/test_e2e.py +21 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/tests/test_git2svn.py +53 -1
- {git2svn-0.5.2 → git2svn-0.6.1}/.githooks/pre-commit +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/.github/dependabot.yml +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/.github/workflows/ci.yml +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/.github/workflows/release.yml +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/.gitignore +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/.pre-commit-config.yaml +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/CONTRIBUTING.md +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/LICENSE.md +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/README.md +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/docs/arc42/README.md +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/docs/arc42/adrs.md +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/docs/arc42/concepts.md +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/docs/req42.md +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/docs/user-guide/README.md +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/docs/user-guide/commands.md +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/docs/user-guide/integration-workflow.md +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/docs/user-guide/troubleshooting.md +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/__main__.py +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/eol.py +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/git.py +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/patcher.py +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/py.typed +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/state.py +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn/svn.py +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/dependency_links.txt +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/entry_points.txt +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/requires.txt +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.egg-info/top_level.txt +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/git2svn.py +0 -0
- {git2svn-0.5.2 → git2svn-0.6.1}/setup.cfg +0 -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
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
|
|
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
|