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.
- {git2svn-0.6.0/git2svn.egg-info → git2svn-0.7.0}/PKG-INFO +1 -1
- {git2svn-0.6.0 → git2svn-0.7.0}/docs/arc42/README.md +24 -2
- {git2svn-0.6.0 → git2svn-0.7.0}/docs/arc42/adrs.md +15 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/__init__.py +6 -1
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/_version.py +2 -2
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/cli.py +23 -129
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/colors.py +44 -1
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/core.py +11 -127
- git2svn-0.7.0/git2svn/setup.py +132 -0
- git2svn-0.7.0/git2svn/status.py +157 -0
- {git2svn-0.6.0 → git2svn-0.7.0/git2svn.egg-info}/PKG-INFO +1 -1
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/SOURCES.txt +10 -1
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/scm_file_list.json +10 -1
- {git2svn-0.6.0 → git2svn-0.7.0}/pyproject.toml +1 -1
- git2svn-0.7.0/tests/test_cli.py +198 -0
- git2svn-0.7.0/tests/test_colors.py +112 -0
- git2svn-0.6.0/tests/test_git2svn.py → git2svn-0.7.0/tests/test_core.py +0 -637
- {git2svn-0.6.0 → git2svn-0.7.0}/tests/test_e2e.py +21 -0
- git2svn-0.7.0/tests/test_eol.py +45 -0
- git2svn-0.7.0/tests/test_git.py +85 -0
- git2svn-0.7.0/tests/test_patcher.py +33 -0
- git2svn-0.7.0/tests/test_status.py +140 -0
- git2svn-0.7.0/tests/test_svn.py +140 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/.githooks/pre-commit +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/.github/dependabot.yml +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/.github/workflows/ci.yml +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/.github/workflows/release.yml +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/.gitignore +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/.pre-commit-config.yaml +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/CONTRIBUTING.md +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/LICENSE.md +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/README.md +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/docs/arc42/concepts.md +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/docs/req42.md +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/docs/user-guide/README.md +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/docs/user-guide/commands.md +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/docs/user-guide/integration-workflow.md +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/docs/user-guide/troubleshooting.md +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/__main__.py +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/eol.py +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/git.py +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/patcher.py +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/py.typed +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/state.py +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn/svn.py +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/dependency_links.txt +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/entry_points.txt +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/requires.txt +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.egg-info/top_level.txt +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/git2svn.py +0 -0
- {git2svn-0.6.0 → git2svn-0.7.0}/setup.cfg +0 -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,
|
|
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-
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (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
|
-
|
|
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
|
-
|
|
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=
|
|
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"
|
|
57
|
-
f"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
528
|
-
c = self.color
|
|
532
|
+
from .status import StatusReporter
|
|
529
533
|
|
|
530
|
-
|
|
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()
|