git2svn 0.4.1__tar.gz → 0.5.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.4.1 → git2svn-0.5.1}/.github/workflows/ci.yml +3 -1
- {git2svn-0.4.1/git2svn.egg-info → git2svn-0.5.1}/PKG-INFO +2 -2
- {git2svn-0.4.1 → git2svn-0.5.1}/README.md +1 -1
- {git2svn-0.4.1 → git2svn-0.5.1}/docs/arc42/adrs.md +12 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/docs/user-guide/commands.md +22 -1
- {git2svn-0.4.1 → git2svn-0.5.1}/docs/user-guide/integration-workflow.md +6 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/docs/user-guide/troubleshooting.md +30 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/__init__.py +4 -1
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/_version.py +2 -2
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/cli.py +25 -3
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/core.py +150 -2
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/git.py +23 -6
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/patcher.py +23 -16
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/svn.py +79 -10
- {git2svn-0.4.1 → git2svn-0.5.1/git2svn.egg-info}/PKG-INFO +2 -2
- {git2svn-0.4.1 → git2svn-0.5.1}/tests/test_e2e.py +148 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/tests/test_git2svn.py +223 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/.githooks/pre-commit +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/.github/dependabot.yml +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/.github/workflows/release.yml +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/.gitignore +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/.pre-commit-config.yaml +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/CONTRIBUTING.md +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/LICENSE.md +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/docs/arc42/README.md +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/docs/arc42/concepts.md +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/docs/req42.md +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/docs/user-guide/README.md +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/__main__.py +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/eol.py +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn/state.py +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/SOURCES.txt +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/dependency_links.txt +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/entry_points.txt +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/requires.txt +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/scm_file_list.json +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.egg-info/top_level.txt +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/git2svn.py +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/pyproject.toml +0 -0
- {git2svn-0.4.1 → git2svn-0.5.1}/setup.cfg +0 -0
|
@@ -65,7 +65,9 @@ jobs:
|
|
|
65
65
|
echo "C:\Program Files\TortoiseSVN\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
|
66
66
|
|
|
67
67
|
- name: Install Dependencies
|
|
68
|
-
run:
|
|
68
|
+
run: |
|
|
69
|
+
python -m pip install --upgrade pip setuptools wheel
|
|
70
|
+
pip install --retries 5 -e .[dev]
|
|
69
71
|
|
|
70
72
|
- name: Run Unit & Integration Tests (with Coverage)
|
|
71
73
|
if: runner.os == 'Linux' && matrix.python-version == '3.12'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git2svn
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
@@ -35,7 +35,7 @@ Dynamic: license-file
|
|
|
35
35
|
<p align="center">
|
|
36
36
|
<a href="https://github.com/simon123h/git2svn/actions/workflows/ci.yml"><img src="https://github.com/simon123h/git2svn/actions/workflows/ci.yml/badge.svg?branch=main" alt="Build Status"></a>
|
|
37
37
|
<a href="https://codecov.io/gh/simon123h/git2svn"><img src="https://codecov.io/gh/simon123h/git2svn/branch/main/graph/badge.svg" alt="Coverage"></a>
|
|
38
|
-
<a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=
|
|
38
|
+
<a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=green" alt="PyPI Version"></a>
|
|
39
39
|
<img src="https://img.shields.io/badge/Python-v3.11+-3776AB?logo=python&logoColor=white" alt="Python Version">
|
|
40
40
|
<img src="https://img.shields.io/endpoint?url=https%3A%2F%2Ftokei.kojix2.net%2Fbadge%2Fgithub%2Fsimon123h%2Fgit2svn%2Flines" alt="Lines of Code">
|
|
41
41
|
<a href="https://github.com/simon123h/git2svn/releases"><img src="https://badgen.net/github/release/simon123h/git2svn?color=orange" alt="Latest Release"></a>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<a href="https://github.com/simon123h/git2svn/actions/workflows/ci.yml"><img src="https://github.com/simon123h/git2svn/actions/workflows/ci.yml/badge.svg?branch=main" alt="Build Status"></a>
|
|
5
5
|
<a href="https://codecov.io/gh/simon123h/git2svn"><img src="https://codecov.io/gh/simon123h/git2svn/branch/main/graph/badge.svg" alt="Coverage"></a>
|
|
6
|
-
<a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=
|
|
6
|
+
<a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=green" alt="PyPI Version"></a>
|
|
7
7
|
<img src="https://img.shields.io/badge/Python-v3.11+-3776AB?logo=python&logoColor=white" alt="Python Version">
|
|
8
8
|
<img src="https://img.shields.io/endpoint?url=https%3A%2F%2Ftokei.kojix2.net%2Fbadge%2Fgithub%2Fsimon123h%2Fgit2svn%2Flines" alt="Lines of Code">
|
|
9
9
|
<a href="https://github.com/simon123h/git2svn/releases"><img src="https://badgen.net/github/release/simon123h/git2svn?color=orange" alt="Latest Release"></a>
|
|
@@ -73,3 +73,15 @@ This document contains the Architecture Decision Records (ADRs) for `git2svn`.
|
|
|
73
73
|
* **Context:** Subversion's mixed-revision working copy design leaves the local root revision behind `HEAD` after commits, hiding new revisions from `svn log` until `svn update` is run.
|
|
74
74
|
* **Decision:** Provide an opt-in `-u` / `--update` flag and `git2svn.autoUpdate` setting to trigger `svn update` upon successful replay completion.
|
|
75
75
|
* **Consequences:** Keeps local working copy base at `HEAD` while allowing users on massive remote SVN repositories to avoid extra network latency.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
### ADR-11: No Author/Metadata Preservation or Date Spoofing
|
|
80
|
+
* **Context:** In Git, commits carry distinct author and committer names, emails, and historical timestamps. Subversion tracks revision properties (`svn:author`, `svn:date`) which are either immutable by default or require custom server-side hook scripts (`pre-revprop-change`) that are typically locked down in enterprise SVN deployments. Modifying revision properties or spoofing commit timestamps/authors can create security, audit, and traceability concerns, in addition to introducing server-dependent replay failures.
|
|
81
|
+
* **Decision:** Explicitly reject author spoofing, date manipulation, or artificial metadata rewriting in `git2svn`. All replayed commits are committed using the active SVN working copy user credentials and the current server timestamp. Original Git commit messages are preserved verbatim, but author identity and commit time in SVN remain authoritative to the person running the tool at the time of execution.
|
|
82
|
+
* **Consequences:**
|
|
83
|
+
- Guarantees compatibility with all SVN servers out of the box without requiring `pre-revprop-change` server hook privileges.
|
|
84
|
+
- Maintains strict organizational accountability: the committer who pushes the changes to SVN is always the verified SVN user.
|
|
85
|
+
- Avoids temporal inconsistencies in SVN repositories where revisions must strictly increase in time.
|
|
86
|
+
- Keeps the codebase lightweight and free of brittle revision-property manipulation logic.
|
|
87
|
+
|
|
@@ -153,10 +153,31 @@ git2svn setup /path/to/svn
|
|
|
153
153
|
5. **Configures Safe Productivity Aliases:**
|
|
154
154
|
- `git config alias.svn-push`: Replays trunk to SVN, fetches SVN mirror, and safely resets trunk *only* if `trunk` matches `svn-mirror/trunk` (guarded by `git diff --quiet`).
|
|
155
155
|
- `git config alias.svn-pull`: Fetches SVN mirror, fast-forwards trunk if clean, and automatically rebases local commits if unpushed work exists on trunk.
|
|
156
|
+
- `git config alias.svn-status`: Inspects synchronization health, pending commits, and workspace state via `git2svn status`.
|
|
156
157
|
|
|
157
158
|
---
|
|
158
159
|
|
|
159
|
-
## 5.
|
|
160
|
+
## 5. Command: `status`
|
|
161
|
+
|
|
162
|
+
Inspects synchronization health, pending commits in `git2svn.defaultRange`, and working tree states across both Git and Subversion.
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
git2svn status
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### What `status` reports:
|
|
169
|
+
- **Git Workspace:** Active branch, latest commit hash & subject, and working tree cleanliness.
|
|
170
|
+
- **SVN Working Copy:** Path, target SVN URL, base revision, cleanliness, and lock status.
|
|
171
|
+
- **Synchronization Queue:** Configured `git2svn.defaultRange`, pending commit count & titles, and linear history validation (warns if merge commits exist).
|
|
172
|
+
- **In-Progress Replay:** State of paused replays (if a conflict occurred), remaining commits, active `.rej` conflict files, and actionable recovery commands (`--continue`, `--abort`, `--skip`).
|
|
173
|
+
|
|
174
|
+
### Exit Codes:
|
|
175
|
+
- `0`: Workspace is clean, unlocked, and synchronization queue is valid.
|
|
176
|
+
- `1`: SVN working copy is locked/dirty, replay is paused due to conflict, or merge commits violate linear history.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## 6. Structural Staging Mechanics
|
|
160
181
|
|
|
161
182
|
During patch application or file copying, `git2svn` maps Git status codes (`git diff --name-status`) to the corresponding Subversion commands:
|
|
162
183
|
|
|
@@ -114,6 +114,9 @@ git config alias.svn-pull "!f() { \
|
|
|
114
114
|
git rebase svn-mirror/trunk; \
|
|
115
115
|
fi; \
|
|
116
116
|
}; f"
|
|
117
|
+
|
|
118
|
+
# 3. git svn-status: Inspects sync health, pending commits, and working tree states
|
|
119
|
+
git config alias.svn-status "!git2svn status"
|
|
117
120
|
```
|
|
118
121
|
|
|
119
122
|
### Safety Features of These Aliases:
|
|
@@ -124,6 +127,9 @@ git config alias.svn-pull "!f() { \
|
|
|
124
127
|
With these aliases configured alongside `git config git2svn.defaultRange "svn-mirror/trunk..trunk"`:
|
|
125
128
|
|
|
126
129
|
```bash
|
|
130
|
+
# Check synchronization health, tree states, and pending commits:
|
|
131
|
+
git svn-status
|
|
132
|
+
|
|
127
133
|
# Pull latest SVN state into local Git trunk (fast-forward or rebase):
|
|
128
134
|
git svn-pull
|
|
129
135
|
|
|
@@ -53,3 +53,33 @@ git2svn replay -u <range>
|
|
|
53
53
|
# Or configure globally:
|
|
54
54
|
git config git2svn.autoUpdate true
|
|
55
55
|
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
### Q: Why does `git2svn` fail with "Subversion working copy is locked" (`E155004`)?
|
|
60
|
+
If Subversion was interrupted mid-operation (e.g. an aborted command, an IDE indexing run, or a background mirror sync), Subversion places lock files in `.svn` to prevent database corruption.
|
|
61
|
+
|
|
62
|
+
To resolve:
|
|
63
|
+
1. Run:
|
|
64
|
+
```bash
|
|
65
|
+
svn cleanup /path/to/svn
|
|
66
|
+
```
|
|
67
|
+
2. If background sync scripts or IDE indexing processes are actively touching the directory, wait for them to finish.
|
|
68
|
+
3. Re-run your `git2svn` command.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### Q: Why does `git2svn` fail with "Item is out of date" or collision (`E155015` / `E160024`)?
|
|
73
|
+
This happens when another developer or automated process committed new revisions to Subversion upstream while your local working copy was still at an older revision. Subversion rejects committing against stale base revisions.
|
|
74
|
+
|
|
75
|
+
To resolve:
|
|
76
|
+
1. Update your SVN working copy to bring it up to HEAD:
|
|
77
|
+
```bash
|
|
78
|
+
svn update /path/to/svn
|
|
79
|
+
```
|
|
80
|
+
2. Check `svn status` to verify there are no tree or content conflicts.
|
|
81
|
+
3. If an in-progress replay was paused, continue it with:
|
|
82
|
+
```bash
|
|
83
|
+
git2svn replay --continue
|
|
84
|
+
```
|
|
85
|
+
|
|
@@ -17,7 +17,7 @@ from .state import (
|
|
|
17
17
|
load_replay_state,
|
|
18
18
|
save_replay_state,
|
|
19
19
|
)
|
|
20
|
-
from .svn import SvnWorkspace
|
|
20
|
+
from .svn import SvnError, SvnLockError, SvnOutOfDateError, SvnWorkspace
|
|
21
21
|
|
|
22
22
|
try:
|
|
23
23
|
from ._version import __version__
|
|
@@ -39,6 +39,9 @@ __all__ = [
|
|
|
39
39
|
"Synchronizer",
|
|
40
40
|
"GitRepo",
|
|
41
41
|
"SvnWorkspace",
|
|
42
|
+
"SvnError",
|
|
43
|
+
"SvnLockError",
|
|
44
|
+
"SvnOutOfDateError",
|
|
42
45
|
"Patcher",
|
|
43
46
|
"FileChange",
|
|
44
47
|
"parse_ref_arguments",
|
|
@@ -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.5.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -12,7 +12,7 @@ from .core import Synchronizer
|
|
|
12
12
|
from .git import GitRepo
|
|
13
13
|
from .patcher import Patcher
|
|
14
14
|
from .state import load_replay_state
|
|
15
|
-
from .svn import SvnWorkspace
|
|
15
|
+
from .svn import SvnError, SvnLockError, SvnOutOfDateError, SvnWorkspace
|
|
16
16
|
|
|
17
17
|
logger = logging.getLogger("git2svn")
|
|
18
18
|
|
|
@@ -195,7 +195,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
195
195
|
"Configure repository settings for git2svn on a fresh clone.\n"
|
|
196
196
|
"Automatically detects local trunk and remote SVN mirror tracking branches,\n"
|
|
197
197
|
"sets git2svn.svnDir, git2svn.defaultRange, git2svn.autoUpdate, pull.ff only,\n"
|
|
198
|
-
"and configures git alias.svn-push and alias.svn-
|
|
198
|
+
"and configures git alias.svn-push, alias.svn-pull, and alias.svn-status."
|
|
199
199
|
),
|
|
200
200
|
)
|
|
201
201
|
parser_setup.add_argument(
|
|
@@ -207,6 +207,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
207
207
|
help="Path to SVN working copy (optional if already configured or set via --svn-dir)",
|
|
208
208
|
)
|
|
209
209
|
|
|
210
|
+
# status
|
|
211
|
+
subparsers.add_parser(
|
|
212
|
+
"status",
|
|
213
|
+
parents=[common_parser],
|
|
214
|
+
help="Inspect synchronization health, pending commits, and workspace state",
|
|
215
|
+
description=(
|
|
216
|
+
"Display current state of Git repository, SVN working copy, in-progress replay,\n"
|
|
217
|
+
"and pending commits in the configured defaultRange."
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
|
|
210
221
|
return parser
|
|
211
222
|
|
|
212
223
|
|
|
@@ -315,9 +326,11 @@ def handle_setup(git_repo: GitRepo, svn_dir_path: Optional[Path | str]) -> int:
|
|
|
315
326
|
f"echo '[git svn-pull] Fast-forward not possible (local commits on {trunk_name}). Rebasing onto {mirror_branch}...'; "
|
|
316
327
|
f"git rebase {mirror_branch}; fi; }}; f"
|
|
317
328
|
)
|
|
329
|
+
status_script = "!git2svn status"
|
|
318
330
|
|
|
319
331
|
git_repo.set_config("alias.svn-push", push_script)
|
|
320
332
|
git_repo.set_config("alias.svn-pull", pull_script)
|
|
333
|
+
git_repo.set_config("alias.svn-status", status_script)
|
|
321
334
|
|
|
322
335
|
print("Successfully configured git2svn:")
|
|
323
336
|
print(f" git2svn.svnDir = {svn_dir_str}")
|
|
@@ -328,6 +341,7 @@ def handle_setup(git_repo: GitRepo, svn_dir_path: Optional[Path | str]) -> int:
|
|
|
328
341
|
print(" pull.ff = only")
|
|
329
342
|
print(f" alias.svn-push = {push_script}")
|
|
330
343
|
print(f" alias.svn-pull = {pull_script}")
|
|
344
|
+
print(f" alias.svn-status = {status_script}")
|
|
331
345
|
return 0
|
|
332
346
|
|
|
333
347
|
|
|
@@ -425,7 +439,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
425
439
|
sync_mgr = Synchronizer(git_repo, svn_workspace, patcher, dry_run=dry_run, auto_update=auto_update)
|
|
426
440
|
|
|
427
441
|
# Fallback to git2svn.defaultRange if ref1 is omitted
|
|
428
|
-
ref1 = args
|
|
442
|
+
ref1 = getattr(args, "ref1", None)
|
|
429
443
|
ref2 = getattr(args, "ref2", None)
|
|
430
444
|
if not ref1 and args.command in ("stage", "replay"):
|
|
431
445
|
# Replay actions (--continue, --abort, --skip) do not need ref1
|
|
@@ -470,8 +484,16 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
470
484
|
)
|
|
471
485
|
return 1
|
|
472
486
|
sync_mgr.replay(ref1, ref2)
|
|
487
|
+
elif args.command == "status":
|
|
488
|
+
return sync_mgr.status()
|
|
473
489
|
else:
|
|
474
490
|
return 1
|
|
491
|
+
except (SvnLockError, SvnOutOfDateError) as e:
|
|
492
|
+
print(f"\n[SVN Error] {e}", file=sys.stderr)
|
|
493
|
+
return e.returncode
|
|
494
|
+
except SvnError as e:
|
|
495
|
+
print(f"\n[SVN Error] {e}", file=sys.stderr)
|
|
496
|
+
return e.returncode
|
|
475
497
|
except subprocess.CalledProcessError as e:
|
|
476
498
|
logger.error("Process failed with returncode %s", e.returncode)
|
|
477
499
|
return e.returncode
|
|
@@ -17,7 +17,7 @@ from .state import (
|
|
|
17
17
|
load_replay_state,
|
|
18
18
|
save_replay_state,
|
|
19
19
|
)
|
|
20
|
-
from .svn import SvnWorkspace
|
|
20
|
+
from .svn import SvnError, SvnWorkspace
|
|
21
21
|
|
|
22
22
|
logger = logging.getLogger("git2svn")
|
|
23
23
|
|
|
@@ -261,13 +261,23 @@ class Synchronizer:
|
|
|
261
261
|
)
|
|
262
262
|
|
|
263
263
|
# 2. Commit the resolved changes for the interrupted commit
|
|
264
|
+
pad_width = len(str(total))
|
|
265
|
+
first_line = current_msg.splitlines()[0] if current_msg else ""
|
|
266
|
+
progress_prefix = f"[{completed + 1:>{pad_width}}/{total}] Resolving {current_commit[:8]}: {first_line}"
|
|
267
|
+
|
|
264
268
|
if not self.svn.is_clean():
|
|
265
269
|
logger.info("Committing resolved commit %s to SVN...", current_commit)
|
|
270
|
+
sys.stdout.write(f"{progress_prefix}... ")
|
|
271
|
+
sys.stdout.flush()
|
|
266
272
|
self.svn.commit(current_msg)
|
|
273
|
+
sys.stdout.write("OK\n")
|
|
274
|
+
sys.stdout.flush()
|
|
267
275
|
else:
|
|
268
276
|
logger.info(
|
|
269
277
|
"No changes in SVN workspace to commit for %s (commit resolved as empty or skipped).", current_commit
|
|
270
278
|
)
|
|
279
|
+
sys.stdout.write(f"{progress_prefix}... SKIPPED (no changes)\n")
|
|
280
|
+
sys.stdout.flush()
|
|
271
281
|
|
|
272
282
|
completed += 1
|
|
273
283
|
logger.info("Commit %s (%d/%d) resolved and committed.", current_commit, completed, total)
|
|
@@ -319,15 +329,35 @@ class Synchronizer:
|
|
|
319
329
|
|
|
320
330
|
def _execute_replay_queue(self, commits: List[str], total_commits: int, start_index: int) -> None:
|
|
321
331
|
"""Execute a list of commits sequentially, catching conflicts and persisting state."""
|
|
332
|
+
pad_width = len(str(total_commits))
|
|
322
333
|
for idx, commit_hash in enumerate(commits, start=start_index):
|
|
323
334
|
commit_msg = self.git.get_commit_message(commit_hash)
|
|
324
335
|
first_line = commit_msg.splitlines()[0] if commit_msg else ""
|
|
325
|
-
|
|
336
|
+
progress_prefix = f"[{idx:>{pad_width}}/{total_commits}] Applying {commit_hash[:8]}: {first_line}"
|
|
337
|
+
logger.info(progress_prefix)
|
|
338
|
+
|
|
339
|
+
# Interactive console progress output
|
|
340
|
+
if sys.stdout.isatty():
|
|
341
|
+
sys.stdout.write(f"\r\033[K{progress_prefix}... ")
|
|
342
|
+
sys.stdout.flush()
|
|
343
|
+
else:
|
|
344
|
+
sys.stdout.write(f"{progress_prefix}... ")
|
|
345
|
+
sys.stdout.flush()
|
|
326
346
|
|
|
327
347
|
try:
|
|
328
348
|
self._patch_and_stage_commit(commit_hash)
|
|
329
349
|
self.svn.commit(commit_msg)
|
|
350
|
+
sys.stdout.write("OK\n")
|
|
351
|
+
sys.stdout.flush()
|
|
352
|
+
except SvnError:
|
|
353
|
+
sys.stdout.write("FAILED (SVN error)\n")
|
|
354
|
+
sys.stdout.flush()
|
|
355
|
+
# SVN operational failure (e.g. working copy locked, out-of-date, collision)
|
|
356
|
+
# Re-raise directly to display actionable SVN resolution hints
|
|
357
|
+
raise
|
|
330
358
|
except Exception:
|
|
359
|
+
sys.stdout.write("CONFLICT\n")
|
|
360
|
+
sys.stdout.flush()
|
|
331
361
|
remaining = commits[idx - start_index + 1 :]
|
|
332
362
|
state_data = {
|
|
333
363
|
"state": "CONFLICT_PAUSED",
|
|
@@ -483,3 +513,121 @@ class Synchronizer:
|
|
|
483
513
|
print(f"[DRY-RUN] Ensure directory exists: {target_file.parent}")
|
|
484
514
|
else:
|
|
485
515
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
516
|
+
|
|
517
|
+
def status(self) -> int:
|
|
518
|
+
"""
|
|
519
|
+
Inspect and display status of Git repo, SVN working copy, in-progress replay, and pending commits.
|
|
520
|
+
Returns:
|
|
521
|
+
0 if clean and ready / in sync
|
|
522
|
+
1 if errors, working copy locked, merge conflicts, or linear violations exist
|
|
523
|
+
"""
|
|
524
|
+
has_error = False
|
|
525
|
+
|
|
526
|
+
# 1. Check in-progress replay state
|
|
527
|
+
state = load_replay_state(self.svn.workspace_dir)
|
|
528
|
+
if state:
|
|
529
|
+
has_error = True
|
|
530
|
+
current_commit = state.get("current_commit", "unknown")
|
|
531
|
+
current_msg = state.get("current_commit_msg", "")
|
|
532
|
+
first_msg = current_msg.splitlines()[0] if current_msg else ""
|
|
533
|
+
remaining = state.get("remaining_commits", [])
|
|
534
|
+
completed = state.get("completed_commits", 0)
|
|
535
|
+
total = state.get("total_commits", len(remaining) + 1)
|
|
536
|
+
|
|
537
|
+
print("[Replay In Progress]")
|
|
538
|
+
print(f' State : PAUSED (conflict at commit {current_commit[:8]} "{first_msg}")')
|
|
539
|
+
print(f" Progress : {completed} of {total} commits applied ({len(remaining)} remaining)")
|
|
540
|
+
|
|
541
|
+
rej_files = find_conflict_artifacts(self.svn.workspace_dir)
|
|
542
|
+
if rej_files:
|
|
543
|
+
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")
|
|
546
|
+
|
|
547
|
+
# 2. Git Workspace Status
|
|
548
|
+
git_clean = self.git.is_clean()
|
|
549
|
+
branch = self.git.get_current_branch()
|
|
550
|
+
head_commit = self.git.get_head_commit()
|
|
551
|
+
head_subject = self.git.get_head_subject()
|
|
552
|
+
head_desc = f'{head_commit} "{head_subject}"' if head_subject else head_commit
|
|
553
|
+
|
|
554
|
+
print("[Git Workspace]")
|
|
555
|
+
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)'}")
|
|
558
|
+
|
|
559
|
+
# 3. SVN Working Copy Status
|
|
560
|
+
print("\n[SVN Working Copy]")
|
|
561
|
+
print(f" Path : {self.svn.workspace_dir}")
|
|
562
|
+
svn_info = self.svn.get_info()
|
|
563
|
+
svn_url = svn_info.get("URL") or svn_info.get("Relative URL") or "unknown"
|
|
564
|
+
svn_rev = svn_info.get("Revision")
|
|
565
|
+
rev_str = f" (r{svn_rev})" if svn_rev else ""
|
|
566
|
+
print(f" Target : {svn_url}{rev_str}")
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
svn_clean = self.svn.is_clean()
|
|
570
|
+
if svn_clean:
|
|
571
|
+
print(" Tree : Clean (no uncommitted changes, unlocked)")
|
|
572
|
+
else:
|
|
573
|
+
uncommitted = self.svn.get_status_summary()
|
|
574
|
+
print(f" Tree : Dirty ({len(uncommitted)} uncommitted changes)")
|
|
575
|
+
for item in uncommitted[:5]:
|
|
576
|
+
print(f" {item}")
|
|
577
|
+
if len(uncommitted) > 5:
|
|
578
|
+
print(f" ... and {len(uncommitted) - 5} more")
|
|
579
|
+
except SvnError as e:
|
|
580
|
+
has_error = True
|
|
581
|
+
print(f" Tree : Error: {e.args[0].splitlines()[0]}")
|
|
582
|
+
|
|
583
|
+
# 4. Synchronization Queue & Default Range
|
|
584
|
+
default_range = self.git.get_config("git2svn.defaultRange")
|
|
585
|
+
print("\n[Synchronization]")
|
|
586
|
+
if not default_range:
|
|
587
|
+
print(" Range : Not configured (run 'git2svn setup' or 'git config git2svn.defaultRange <range>')")
|
|
588
|
+
else:
|
|
589
|
+
print(f" Range : {default_range}")
|
|
590
|
+
try:
|
|
591
|
+
is_single, start_ref, end_ref = parse_ref_arguments(default_range)
|
|
592
|
+
if is_single:
|
|
593
|
+
commits = [start_ref] if self.git.ref_exists(start_ref) else []
|
|
594
|
+
merges = []
|
|
595
|
+
else:
|
|
596
|
+
assert end_ref is not None
|
|
597
|
+
if not self.git.ref_exists(start_ref):
|
|
598
|
+
print(f" Warning : Range start ref '{start_ref}' not found in Git.")
|
|
599
|
+
commits = []
|
|
600
|
+
merges = []
|
|
601
|
+
elif not self.git.ref_exists(end_ref):
|
|
602
|
+
print(f" Warning : Range end ref '{end_ref}' not found in Git.")
|
|
603
|
+
commits = []
|
|
604
|
+
merges = []
|
|
605
|
+
else:
|
|
606
|
+
merges = self.git.get_merge_commits(start_ref, end_ref)
|
|
607
|
+
commits = self.git.get_commit_range(start_ref, end_ref)
|
|
608
|
+
|
|
609
|
+
if merges:
|
|
610
|
+
has_error = True
|
|
611
|
+
print(
|
|
612
|
+
f" Linearity : INVALID ({len(merges)} merge commits found in range - linear rebase required)"
|
|
613
|
+
)
|
|
614
|
+
else:
|
|
615
|
+
print(" Linearity : OK (strictly linear)")
|
|
616
|
+
|
|
617
|
+
if not commits:
|
|
618
|
+
print(" Pending : In sync (0 commits to replay)")
|
|
619
|
+
else:
|
|
620
|
+
print(f" Pending : {len(commits)} commit(s) ready to replay:")
|
|
621
|
+
for idx, c_hash in enumerate(commits[:10], start=1):
|
|
622
|
+
msg = self.git.get_commit_message(c_hash)
|
|
623
|
+
subject = msg.splitlines()[0] if msg else ""
|
|
624
|
+
print(f" {idx}. [{c_hash[:8]}] {subject}")
|
|
625
|
+
if len(commits) > 10:
|
|
626
|
+
print(f" ... and {len(commits) - 10} more")
|
|
627
|
+
except Exception as e:
|
|
628
|
+
print(f" Error : Could not parse range '{default_range}': {e}")
|
|
629
|
+
has_error = True
|
|
630
|
+
|
|
631
|
+
if has_error:
|
|
632
|
+
return 1
|
|
633
|
+
return 0
|
|
@@ -187,6 +187,11 @@ class GitRepo:
|
|
|
187
187
|
return branch if branch != "HEAD" else "HEAD (detached)"
|
|
188
188
|
return "unknown"
|
|
189
189
|
|
|
190
|
+
def is_clean(self) -> bool:
|
|
191
|
+
"""Check if Git working tree and index have no uncommitted changes."""
|
|
192
|
+
res = self.run_cmd(["status", "--porcelain"], check=False)
|
|
193
|
+
return not bool(res.stdout.strip())
|
|
194
|
+
|
|
190
195
|
def get_head_commit(self) -> str:
|
|
191
196
|
"""Return the short hash of HEAD."""
|
|
192
197
|
res = self.run_cmd(["rev-parse", "--short", "HEAD"], check=False)
|
|
@@ -194,6 +199,18 @@ class GitRepo:
|
|
|
194
199
|
return res.stdout.strip()
|
|
195
200
|
return "unknown"
|
|
196
201
|
|
|
202
|
+
def get_head_subject(self) -> str:
|
|
203
|
+
"""Return the subject line of the HEAD commit."""
|
|
204
|
+
res = self.run_cmd(["log", "-1", "--format=%s", "HEAD"], check=False)
|
|
205
|
+
if res.returncode == 0:
|
|
206
|
+
return res.stdout.strip()
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
def ref_exists(self, ref: str) -> bool:
|
|
210
|
+
"""Check whether a Git reference or revision expression resolves."""
|
|
211
|
+
res = self.run_cmd(["rev-parse", "--verify", "--quiet", ref], check=False)
|
|
212
|
+
return res.returncode == 0
|
|
213
|
+
|
|
197
214
|
def get_commit_parent(self, commit_hash: str) -> Optional[str]:
|
|
198
215
|
"""Return the parent commit hash, or None if root commit."""
|
|
199
216
|
res = self.run_cmd(["rev-parse", "--verify", f"{commit_hash}^"], check=False)
|
|
@@ -201,15 +218,15 @@ class GitRepo:
|
|
|
201
218
|
return res.stdout.strip()
|
|
202
219
|
return None
|
|
203
220
|
|
|
204
|
-
def get_diff(self, ref_spec: str) ->
|
|
205
|
-
"""Get unified diff for the given reference specification."""
|
|
206
|
-
res = self.
|
|
221
|
+
def get_diff(self, ref_spec: str) -> bytes:
|
|
222
|
+
"""Get unified diff for the given reference specification as raw bytes."""
|
|
223
|
+
res = self.run_cmd_bytes(["diff", "--binary", ref_spec])
|
|
207
224
|
return res.stdout
|
|
208
225
|
|
|
209
|
-
def get_diff_root(self, commit_hash: str) ->
|
|
210
|
-
"""Get unified diff for a root commit."""
|
|
226
|
+
def get_diff_root(self, commit_hash: str) -> bytes:
|
|
227
|
+
"""Get unified diff for a root commit as raw bytes."""
|
|
211
228
|
empty_tree_hash = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
212
|
-
res = self.
|
|
229
|
+
res = self.run_cmd_bytes(["diff", "--binary", empty_tree_hash, commit_hash])
|
|
213
230
|
return res.stdout
|
|
214
231
|
|
|
215
232
|
def get_name_status(self, ref_spec: str) -> List[FileChange]:
|
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
import subprocess
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Union
|
|
7
8
|
|
|
8
9
|
logger = logging.getLogger("git2svn")
|
|
9
10
|
|
|
@@ -16,43 +17,49 @@ class Patcher:
|
|
|
16
17
|
self.git_bin = git_bin
|
|
17
18
|
self.dry_run = dry_run
|
|
18
19
|
|
|
19
|
-
def apply_diff(self, diff_content: str) -> None:
|
|
20
|
-
"""Apply unified diff text to target_dir using git apply."""
|
|
21
|
-
if
|
|
20
|
+
def apply_diff(self, diff_content: Union[str, bytes]) -> None:
|
|
21
|
+
"""Apply unified diff text or bytes to target_dir using git apply."""
|
|
22
|
+
if isinstance(diff_content, str):
|
|
23
|
+
diff_bytes = diff_content.encode("utf-8")
|
|
24
|
+
else:
|
|
25
|
+
diff_bytes = diff_content
|
|
26
|
+
|
|
27
|
+
if not diff_bytes.strip():
|
|
22
28
|
logger.info("Diff is empty. Nothing to patch.")
|
|
23
29
|
return
|
|
24
30
|
|
|
25
31
|
cmd = [self.git_bin, "apply", "--ignore-whitespace", "--unsafe-paths", "--reject"]
|
|
26
32
|
if self.dry_run:
|
|
27
|
-
|
|
33
|
+
preview = diff_bytes.strip()[:200].decode("utf-8", errors="replace")
|
|
34
|
+
print(f"[DRY-RUN] (in {self.target_dir}) {' '.join(cmd)} << EOF\n{preview}...\nEOF")
|
|
28
35
|
return
|
|
29
36
|
|
|
30
37
|
logger.debug("Applying patch to %s using %s", self.target_dir, " ".join(cmd))
|
|
31
38
|
proc = subprocess.run(
|
|
32
39
|
cmd,
|
|
33
40
|
cwd=self.target_dir,
|
|
34
|
-
input=
|
|
41
|
+
input=diff_bytes,
|
|
35
42
|
capture_output=True,
|
|
36
|
-
text=True,
|
|
37
|
-
encoding="utf-8",
|
|
38
|
-
errors="replace",
|
|
39
43
|
)
|
|
40
44
|
|
|
45
|
+
stdout_str = proc.stdout.decode("utf-8", errors="replace") if proc.stdout else ""
|
|
46
|
+
stderr_str = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
|
|
47
|
+
|
|
41
48
|
if proc.returncode != 0:
|
|
42
49
|
logger.error(
|
|
43
|
-
"git apply failed with code %d:\nSTDOUT:\n%s\nSTDERR:\n%s", proc.returncode,
|
|
50
|
+
"git apply failed with code %d:\nSTDOUT:\n%s\nSTDERR:\n%s", proc.returncode, stdout_str, stderr_str
|
|
44
51
|
)
|
|
45
52
|
print(f"Error: git apply failed (exit code {proc.returncode}).", file=sys.stderr)
|
|
46
|
-
if
|
|
47
|
-
print(
|
|
48
|
-
if
|
|
49
|
-
print(
|
|
53
|
+
if stdout_str:
|
|
54
|
+
print(stdout_str, file=sys.stderr)
|
|
55
|
+
if stderr_str:
|
|
56
|
+
print(stderr_str, file=sys.stderr)
|
|
50
57
|
print(
|
|
51
58
|
"Tip: Check for .rej reject files in the SVN workspace or use 'git2svn stage --copy' to copy files directly.",
|
|
52
59
|
file=sys.stderr,
|
|
53
60
|
)
|
|
54
|
-
raise subprocess.CalledProcessError(proc.returncode, cmd,
|
|
61
|
+
raise subprocess.CalledProcessError(proc.returncode, cmd, stdout_str, stderr_str)
|
|
55
62
|
else:
|
|
56
|
-
if
|
|
57
|
-
msg = (
|
|
63
|
+
if stdout_str or stderr_str:
|
|
64
|
+
msg = (stdout_str + "\n" + stderr_str).strip()
|
|
58
65
|
logger.info("Patch applied successfully:\n%s", msg)
|
|
@@ -32,6 +32,72 @@ def find_svn_binary() -> str:
|
|
|
32
32
|
return "svn"
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
class SvnError(RuntimeError):
|
|
36
|
+
"""Base exception for Subversion command failures."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, stderr: str = "", returncode: int = 1):
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.stderr = stderr
|
|
41
|
+
self.returncode = returncode
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SvnLockError(SvnError):
|
|
45
|
+
"""Raised when the SVN working copy or a file is locked (e.g. E155004)."""
|
|
46
|
+
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SvnOutOfDateError(SvnError):
|
|
51
|
+
"""Raised when an SVN commit or update fails due to out-of-date items or collision (e.g. E155015 / E160024)."""
|
|
52
|
+
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_svn_error(stderr: str, action_desc: str, svn_dir: Path) -> SvnError:
|
|
57
|
+
"""Analyze Subversion stderr and construct actionable SvnError with cleanup hints."""
|
|
58
|
+
lower_err = stderr.lower()
|
|
59
|
+
clean_stderr = stderr.strip()
|
|
60
|
+
|
|
61
|
+
# 1. Working copy locked (E155004 / "is already locked" / "run 'svn cleanup'")
|
|
62
|
+
if (
|
|
63
|
+
"e155004" in lower_err
|
|
64
|
+
or "working copy locked" in lower_err
|
|
65
|
+
or "already locked" in lower_err
|
|
66
|
+
or "run 'svn cleanup'" in lower_err
|
|
67
|
+
):
|
|
68
|
+
hint = (
|
|
69
|
+
f"Subversion working copy at '{svn_dir}' is locked.\n"
|
|
70
|
+
f"Details from SVN:\n {clean_stderr}\n\n"
|
|
71
|
+
f"To resolve:\n"
|
|
72
|
+
f' 1. Run: svn cleanup "{svn_dir}"\n'
|
|
73
|
+
f" 2. If a background process (IDE, mirror sync, indexing) is running, wait for it to finish.\n"
|
|
74
|
+
f" 3. Retry your git2svn command."
|
|
75
|
+
)
|
|
76
|
+
return SvnLockError(hint, stderr=clean_stderr)
|
|
77
|
+
|
|
78
|
+
# 2. Out of date / collision (E155015 / E160024 / E155011 / "out of date" / "item is out of date")
|
|
79
|
+
if (
|
|
80
|
+
"e155015" in lower_err
|
|
81
|
+
or "e160024" in lower_err
|
|
82
|
+
or "e155011" in lower_err
|
|
83
|
+
or "out of date" in lower_err
|
|
84
|
+
or "conflict" in lower_err
|
|
85
|
+
):
|
|
86
|
+
hint = (
|
|
87
|
+
f"Subversion working copy at '{svn_dir}' is out of date or conflicted.\n"
|
|
88
|
+
f"Details from SVN:\n {clean_stderr}\n\n"
|
|
89
|
+
f"To resolve:\n"
|
|
90
|
+
f' 1. Run: svn update "{svn_dir}"\n'
|
|
91
|
+
f" 2. Resolve any SVN conflicts if present ('svn status').\n"
|
|
92
|
+
f" 3. Retry your git2svn command (e.g. 'git2svn replay --continue' or retry replay)."
|
|
93
|
+
)
|
|
94
|
+
return SvnOutOfDateError(hint, stderr=clean_stderr)
|
|
95
|
+
|
|
96
|
+
# Generic SVN error with context
|
|
97
|
+
msg = f"Failed to {action_desc} in '{svn_dir}':\n {clean_stderr}"
|
|
98
|
+
return SvnError(msg, stderr=clean_stderr)
|
|
99
|
+
|
|
100
|
+
|
|
35
101
|
class SvnWorkspace:
|
|
36
102
|
"""Wrapper around SVN commands and filesystem staging operations."""
|
|
37
103
|
|
|
@@ -83,8 +149,17 @@ class SvnWorkspace:
|
|
|
83
149
|
def is_clean(self) -> bool:
|
|
84
150
|
"""Check if SVN workspace has no uncommitted changes."""
|
|
85
151
|
res = self.run_cmd(["status", "-q"], check=False)
|
|
152
|
+
if res.returncode != 0:
|
|
153
|
+
raise parse_svn_error(res.stderr, "check status", self.workspace_dir)
|
|
86
154
|
return not bool(res.stdout.strip())
|
|
87
155
|
|
|
156
|
+
def get_status_summary(self) -> List[str]:
|
|
157
|
+
"""Return lines of uncommitted changes from 'svn status -q'."""
|
|
158
|
+
res = self.run_cmd(["status", "-q"], check=False)
|
|
159
|
+
if res.returncode != 0:
|
|
160
|
+
raise parse_svn_error(res.stderr, "check status", self.workspace_dir)
|
|
161
|
+
return [line.rstrip() for line in res.stdout.splitlines() if line.strip()]
|
|
162
|
+
|
|
88
163
|
def get_versioned_files(self) -> List[Path]:
|
|
89
164
|
"""
|
|
90
165
|
Return list of all versioned file paths in the SVN workspace (relative to workspace_dir).
|
|
@@ -143,9 +218,7 @@ class SvnWorkspace:
|
|
|
143
218
|
if res.returncode != 0:
|
|
144
219
|
if "is already under version control" not in res.stderr and "already exists" not in res.stderr:
|
|
145
220
|
logger.error("Failed to 'svn add %s': %s", posix_path, res.stderr.strip())
|
|
146
|
-
raise
|
|
147
|
-
res.returncode, [self.svn_bin, "add", posix_path], res.stdout, res.stderr
|
|
148
|
-
)
|
|
221
|
+
raise parse_svn_error(res.stderr, f"add '{posix_path}'", self.workspace_dir)
|
|
149
222
|
|
|
150
223
|
def stage_rm(self, rel_path: Path) -> None:
|
|
151
224
|
"""Run svn rm <filepath>."""
|
|
@@ -156,9 +229,7 @@ class SvnWorkspace:
|
|
|
156
229
|
logger.warning("File %s not under SVN control to remove.", posix_path)
|
|
157
230
|
else:
|
|
158
231
|
logger.error("Failed to 'svn rm %s': %s", posix_path, res.stderr.strip())
|
|
159
|
-
raise
|
|
160
|
-
res.returncode, [self.svn_bin, "rm", posix_path], res.stdout, res.stderr
|
|
161
|
-
)
|
|
232
|
+
raise parse_svn_error(res.stderr, f"remove '{posix_path}'", self.workspace_dir)
|
|
162
233
|
|
|
163
234
|
def commit(self, message: str) -> None:
|
|
164
235
|
"""Run svn commit using a temporary file with -F to support arbitrary message lengths and encodings."""
|
|
@@ -175,9 +246,7 @@ class SvnWorkspace:
|
|
|
175
246
|
res = self.run_cmd(["commit", "-F", str(tf_path)], check=False)
|
|
176
247
|
if res.returncode != 0:
|
|
177
248
|
logger.error("Failed to 'svn commit': %s", res.stderr.strip())
|
|
178
|
-
raise
|
|
179
|
-
res.returncode, [self.svn_bin, "commit", "-F", str(tf_path)], res.stdout, res.stderr
|
|
180
|
-
)
|
|
249
|
+
raise parse_svn_error(res.stderr, "commit", self.workspace_dir)
|
|
181
250
|
if res.stdout:
|
|
182
251
|
logger.info("SVN commit output:\n%s", res.stdout.strip())
|
|
183
252
|
finally:
|
|
@@ -220,6 +289,6 @@ class SvnWorkspace:
|
|
|
220
289
|
res = self.run_cmd(["update"], check=False)
|
|
221
290
|
if res.returncode != 0:
|
|
222
291
|
logger.error("Failed to 'svn update': %s", res.stderr.strip())
|
|
223
|
-
raise
|
|
292
|
+
raise parse_svn_error(res.stderr, "update", self.workspace_dir)
|
|
224
293
|
if res.stdout:
|
|
225
294
|
logger.info("SVN update output:\n%s", res.stdout.strip())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git2svn
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
@@ -35,7 +35,7 @@ Dynamic: license-file
|
|
|
35
35
|
<p align="center">
|
|
36
36
|
<a href="https://github.com/simon123h/git2svn/actions/workflows/ci.yml"><img src="https://github.com/simon123h/git2svn/actions/workflows/ci.yml/badge.svg?branch=main" alt="Build Status"></a>
|
|
37
37
|
<a href="https://codecov.io/gh/simon123h/git2svn"><img src="https://codecov.io/gh/simon123h/git2svn/branch/main/graph/badge.svg" alt="Coverage"></a>
|
|
38
|
-
<a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=
|
|
38
|
+
<a href="https://pypi.org/project/git2svn/"><img src="https://img.shields.io/pypi/v/git2svn?color=green" alt="PyPI Version"></a>
|
|
39
39
|
<img src="https://img.shields.io/badge/Python-v3.11+-3776AB?logo=python&logoColor=white" alt="Python Version">
|
|
40
40
|
<img src="https://img.shields.io/endpoint?url=https%3A%2F%2Ftokei.kojix2.net%2Fbadge%2Fgithub%2Fsimon123h%2Fgit2svn%2Flines" alt="Lines of Code">
|
|
41
41
|
<a href="https://github.com/simon123h/git2svn/releases"><img src="https://badgen.net/github/release/simon123h/git2svn?color=orange" alt="Latest Release"></a>
|
|
@@ -232,3 +232,151 @@ class TestGit2SvnE2E(unittest.TestCase):
|
|
|
232
232
|
status = self._svn_status()
|
|
233
233
|
self.assertIn("D obsolete.txt", status)
|
|
234
234
|
self.assertIn("A brand_new.txt", status)
|
|
235
|
+
|
|
236
|
+
def test_e2e_replay_conflict_pause_and_continue(self):
|
|
237
|
+
"""Test replay pausing on a patch conflict in a real SVN WC and resuming with --continue."""
|
|
238
|
+
# 1. Base commit in Git and SVN
|
|
239
|
+
f_git = self.git_dir / "conflict_target.txt"
|
|
240
|
+
f_git.write_text("line 1\nline 2\nline 3\n", encoding="utf-8")
|
|
241
|
+
base_hash = self._git_commit("base: conflict target")
|
|
242
|
+
|
|
243
|
+
(self.svn_wc_dir / "conflict_target.txt").write_text("line 1\nline 2\nline 3\n", encoding="utf-8")
|
|
244
|
+
subprocess.run([SVN_BIN, "add", "conflict_target.txt"], cwd=self.svn_wc_dir, check=True)
|
|
245
|
+
subprocess.run([SVN_BIN, "commit", "-m", "init conflict target"], cwd=self.svn_wc_dir, check=True)
|
|
246
|
+
|
|
247
|
+
# 2. Modify in SVN out-of-band to cause conflict
|
|
248
|
+
(self.svn_wc_dir / "conflict_target.txt").write_text("line 1 DIFFERENT\nline 2\nline 3\n", encoding="utf-8")
|
|
249
|
+
subprocess.run([SVN_BIN, "commit", "-m", "remote edit in svn"], cwd=self.svn_wc_dir, check=True)
|
|
250
|
+
subprocess.run([SVN_BIN, "update"], cwd=self.svn_wc_dir, check=True)
|
|
251
|
+
|
|
252
|
+
# 3. Git commit 1 (touches line 1 and 2, which conflicts)
|
|
253
|
+
f_git.write_text("line 1 GIT_EDIT\nline 2 GIT_EDIT\nline 3\n", encoding="utf-8")
|
|
254
|
+
self._git_commit("feat: conflicting git commit")
|
|
255
|
+
|
|
256
|
+
# 4. Git commit 2 (subsequent non-conflicting commit)
|
|
257
|
+
f_next = self.git_dir / "subsequent.txt"
|
|
258
|
+
f_next.write_text("subsequent content\n", encoding="utf-8")
|
|
259
|
+
c2_hash = self._git_commit("feat: subsequent commit")
|
|
260
|
+
|
|
261
|
+
# 5. Run replay range - should pause with non-zero exit code due to conflict
|
|
262
|
+
exit_code = cli_main(["replay", f"{base_hash}..{c2_hash}", "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
|
|
263
|
+
self.assertNotEqual(exit_code, 0)
|
|
264
|
+
|
|
265
|
+
# Verify .rej artifact exists
|
|
266
|
+
rej_file = self.svn_wc_dir / "conflict_target.txt.rej"
|
|
267
|
+
self.assertTrue(rej_file.exists())
|
|
268
|
+
|
|
269
|
+
# 6. Resolve conflict manually in SVN working copy and remove .rej artifact
|
|
270
|
+
(self.svn_wc_dir / "conflict_target.txt").write_text(
|
|
271
|
+
"line 1 RESOLVED\nline 2 GIT_EDIT\nline 3\n", encoding="utf-8"
|
|
272
|
+
)
|
|
273
|
+
rej_file.unlink()
|
|
274
|
+
|
|
275
|
+
# 7. Continue replay
|
|
276
|
+
cont_code = cli_main(["replay", "--continue", "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
|
|
277
|
+
self.assertEqual(cont_code, 0)
|
|
278
|
+
|
|
279
|
+
# Verify SVN working copy is clean and both commits are in SVN log
|
|
280
|
+
self.assertEqual(self._svn_status().strip(), "")
|
|
281
|
+
_, full_log = self._svn_log_messages()
|
|
282
|
+
self.assertIn("conflicting git commit", full_log)
|
|
283
|
+
self.assertIn("subsequent commit", full_log)
|
|
284
|
+
|
|
285
|
+
# Verify final files on disk in SVN
|
|
286
|
+
self.assertEqual(
|
|
287
|
+
(self.svn_wc_dir / "conflict_target.txt").read_text(encoding="utf-8"),
|
|
288
|
+
"line 1 RESOLVED\nline 2 GIT_EDIT\nline 3\n",
|
|
289
|
+
)
|
|
290
|
+
self.assertEqual((self.svn_wc_dir / "subsequent.txt").read_text(encoding="utf-8"), "subsequent content\n")
|
|
291
|
+
|
|
292
|
+
def test_e2e_file_renames_and_moves(self):
|
|
293
|
+
"""Test replay correctly handles file renames and moves across directories in real SVN."""
|
|
294
|
+
# 1. Base commit: create src/old_name.txt
|
|
295
|
+
src_dir = self.git_dir / "src"
|
|
296
|
+
src_dir.mkdir()
|
|
297
|
+
orig_file = src_dir / "old_name.txt"
|
|
298
|
+
orig_file.write_text("print('hello rename')\n", encoding="utf-8")
|
|
299
|
+
base_hash = self._git_commit("init: original file")
|
|
300
|
+
|
|
301
|
+
# Replay base to SVN
|
|
302
|
+
code_base = cli_main(["replay", base_hash, "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
|
|
303
|
+
self.assertEqual(code_base, 0)
|
|
304
|
+
|
|
305
|
+
# 2. Git commit: rename src/old_name.txt -> src/nested/renamed.txt
|
|
306
|
+
nested_dir = src_dir / "nested"
|
|
307
|
+
nested_dir.mkdir()
|
|
308
|
+
subprocess.run([GIT_BIN, "mv", "src/old_name.txt", "src/nested/renamed.txt"], cwd=self.git_dir, check=True)
|
|
309
|
+
rename_hash = self._git_commit("refactor: move and rename file")
|
|
310
|
+
|
|
311
|
+
# 3. Replay rename commit to SVN
|
|
312
|
+
code_rename = cli_main(
|
|
313
|
+
["replay", f"{base_hash}..{rename_hash}", "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)]
|
|
314
|
+
)
|
|
315
|
+
self.assertEqual(code_rename, 0)
|
|
316
|
+
|
|
317
|
+
# 4. Verify SVN state
|
|
318
|
+
self.assertFalse((self.svn_wc_dir / "src" / "old_name.txt").exists())
|
|
319
|
+
renamed_svn = self.svn_wc_dir / "src" / "nested" / "renamed.txt"
|
|
320
|
+
self.assertTrue(renamed_svn.is_file())
|
|
321
|
+
self.assertEqual(renamed_svn.read_text(encoding="utf-8"), "print('hello rename')\n")
|
|
322
|
+
self.assertEqual(self._svn_status().strip(), "")
|
|
323
|
+
|
|
324
|
+
def test_e2e_binary_files(self):
|
|
325
|
+
"""Test replay correctly handles adding and modifying binary files with null bytes in real SVN."""
|
|
326
|
+
# 1. Root commit
|
|
327
|
+
(self.git_dir / "init.txt").write_text("init\n")
|
|
328
|
+
root_hash = self._git_commit("init: root commit")
|
|
329
|
+
|
|
330
|
+
# Replay root commit to SVN
|
|
331
|
+
code_root = cli_main(["replay", root_hash, "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
|
|
332
|
+
self.assertEqual(code_root, 0)
|
|
333
|
+
|
|
334
|
+
# 2. Add binary file
|
|
335
|
+
bin_git = self.git_dir / "image.bin"
|
|
336
|
+
initial_bytes = bytes([0x00, 0xFF, 0xFE, 0x01, 0x00, 0x42, 0xAA, 0x55] * 32)
|
|
337
|
+
bin_git.write_bytes(initial_bytes)
|
|
338
|
+
self._git_commit("feat: add binary image")
|
|
339
|
+
|
|
340
|
+
# 3. Modify binary file
|
|
341
|
+
modified_bytes = bytes([0x00, 0x00, 0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA] * 40)
|
|
342
|
+
bin_git.write_bytes(modified_bytes)
|
|
343
|
+
mod_hash = self._git_commit("fix: update binary bytes")
|
|
344
|
+
|
|
345
|
+
# 4. Replay range to SVN
|
|
346
|
+
code = cli_main(["replay", f"{root_hash}..{mod_hash}", "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
|
|
347
|
+
self.assertEqual(code, 0)
|
|
348
|
+
|
|
349
|
+
# 4. Verify SVN file content is bit-for-bit identical
|
|
350
|
+
bin_svn = self.svn_wc_dir / "image.bin"
|
|
351
|
+
self.assertTrue(bin_svn.is_file())
|
|
352
|
+
self.assertEqual(bin_svn.read_bytes(), modified_bytes)
|
|
353
|
+
self.assertEqual(self._svn_status().strip(), "")
|
|
354
|
+
|
|
355
|
+
def test_e2e_status_real_repo(self):
|
|
356
|
+
"""Test git2svn status command execution against real Git and real SVN repositories."""
|
|
357
|
+
# Setup base commit in Git and SVN
|
|
358
|
+
f = self.git_dir / "status_test.txt"
|
|
359
|
+
f.write_text("status test\n", encoding="utf-8")
|
|
360
|
+
base_hash = self._git_commit("init: status test file")
|
|
361
|
+
|
|
362
|
+
cli_main(["replay", base_hash, "-g", str(self.git_dir), "-s", str(self.svn_wc_dir)])
|
|
363
|
+
|
|
364
|
+
# Setup pending commit in Git
|
|
365
|
+
f.write_text("status test modified\n", encoding="utf-8")
|
|
366
|
+
pending_hash = self._git_commit("feat: pending feature for status")
|
|
367
|
+
|
|
368
|
+
# Configure defaultRange and svnDir in Git config
|
|
369
|
+
subprocess.run(
|
|
370
|
+
[GIT_BIN, "config", "git2svn.defaultRange", f"{base_hash}..{pending_hash}"],
|
|
371
|
+
cwd=self.git_dir,
|
|
372
|
+
check=True,
|
|
373
|
+
)
|
|
374
|
+
subprocess.run(
|
|
375
|
+
[GIT_BIN, "config", "git2svn.svnDir", str(self.svn_wc_dir)],
|
|
376
|
+
cwd=self.git_dir,
|
|
377
|
+
check=True,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Run git2svn status
|
|
381
|
+
exit_code = cli_main(["status", "-g", str(self.git_dir)])
|
|
382
|
+
self.assertEqual(exit_code, 0)
|
|
@@ -667,6 +667,34 @@ class TestSynchronizer(unittest.TestCase):
|
|
|
667
667
|
self.assertTrue((self.svn_path / "trunk_feat.txt").is_file())
|
|
668
668
|
self.assertEqual((self.svn_path / "trunk_feat.txt").read_text(), "trunk feat\n")
|
|
669
669
|
|
|
670
|
+
@patch.object(git2svn.SvnWorkspace, "run_cmd")
|
|
671
|
+
def test_replay_progress_output(self, mock_svn_cmd):
|
|
672
|
+
"""Verify git2svn replay outputs formatted progress indicators with commit title and OK status."""
|
|
673
|
+
import io
|
|
674
|
+
|
|
675
|
+
mock_svn_cmd.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
|
676
|
+
|
|
677
|
+
base_file = self.git_path / "prog_base.txt"
|
|
678
|
+
base_file.write_text("prog base\n")
|
|
679
|
+
subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
|
|
680
|
+
subprocess.run(["git", "commit", "-m", "prog base"], cwd=self.git_path, check=True)
|
|
681
|
+
base_hash = subprocess.run(
|
|
682
|
+
["git", "rev-parse", "HEAD"], cwd=self.git_path, capture_output=True, text=True
|
|
683
|
+
).stdout.strip()
|
|
684
|
+
|
|
685
|
+
commit_file = self.git_path / "prog_feat.txt"
|
|
686
|
+
commit_file.write_text("prog feat\n")
|
|
687
|
+
subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
|
|
688
|
+
subprocess.run(["git", "commit", "-m", "feat: progress indicator"], cwd=self.git_path, check=True)
|
|
689
|
+
feat_hash = subprocess.run(
|
|
690
|
+
["git", "rev-parse", "HEAD"], cwd=self.git_path, capture_output=True, text=True
|
|
691
|
+
).stdout.strip()
|
|
692
|
+
|
|
693
|
+
with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
|
|
694
|
+
self.sync_mgr.replay(base_hash, feat_hash)
|
|
695
|
+
output = mock_stdout.getvalue()
|
|
696
|
+
self.assertIn(f"[1/1] Applying {feat_hash[:8]}: feat: progress indicator... OK", output)
|
|
697
|
+
|
|
670
698
|
@patch.object(git2svn.SvnWorkspace, "run_cmd")
|
|
671
699
|
def test_stage_with_git_config_default_range(self, mock_svn_cmd):
|
|
672
700
|
"""Verify git2svn stage with no ref arguments falls back to git2svn.defaultRange."""
|
|
@@ -954,6 +982,7 @@ class TestEolUtilities(unittest.TestCase):
|
|
|
954
982
|
self.assertEqual(git_repo.get_config("pull.ff"), "only")
|
|
955
983
|
self.assertIn("git2svn replay", git_repo.get_config("alias.svn-push"))
|
|
956
984
|
self.assertIn("git fetch svn-mirror", git_repo.get_config("alias.svn-pull"))
|
|
985
|
+
self.assertEqual(git_repo.get_config("alias.svn-status"), "!git2svn status")
|
|
957
986
|
|
|
958
987
|
def test_setup_interactive_input(self):
|
|
959
988
|
"""Verify git2svn setup prompts for svn path via input() when tty."""
|
|
@@ -1040,5 +1069,199 @@ class TestEolUtilities(unittest.TestCase):
|
|
|
1040
1069
|
self.assertEqual(git2svn.svn.find_svn_binary(), "svn")
|
|
1041
1070
|
|
|
1042
1071
|
|
|
1072
|
+
class TestSvnLockAndCollisionHandling(unittest.TestCase):
|
|
1073
|
+
def setUp(self):
|
|
1074
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
1075
|
+
self.path = Path(self.temp_dir.name).resolve()
|
|
1076
|
+
self.workspace = git2svn.SvnWorkspace(self.path)
|
|
1077
|
+
|
|
1078
|
+
def tearDown(self):
|
|
1079
|
+
self.temp_dir.cleanup()
|
|
1080
|
+
|
|
1081
|
+
def test_parse_svn_error_lock(self):
|
|
1082
|
+
stderr = "svn: E155004: Working copy '/tmp/svn_wc' locked.\nsvn: run 'svn cleanup' to remove locks"
|
|
1083
|
+
err = git2svn.svn.parse_svn_error(stderr, "commit", self.path)
|
|
1084
|
+
self.assertIsInstance(err, git2svn.SvnLockError)
|
|
1085
|
+
self.assertIn("is locked", str(err))
|
|
1086
|
+
self.assertIn("svn cleanup", str(err))
|
|
1087
|
+
|
|
1088
|
+
def test_parse_svn_error_out_of_date(self):
|
|
1089
|
+
stderr = "svn: E155015: Aborting commit: 'file.txt' remains in conflict / item is out of date"
|
|
1090
|
+
err = git2svn.svn.parse_svn_error(stderr, "commit", self.path)
|
|
1091
|
+
self.assertIsInstance(err, git2svn.SvnOutOfDateError)
|
|
1092
|
+
self.assertIn("out of date", str(err))
|
|
1093
|
+
self.assertIn("svn update", str(err))
|
|
1094
|
+
|
|
1095
|
+
def test_parse_svn_error_e160024_conflict(self):
|
|
1096
|
+
stderr = "svn: E160024: resource out of date; try updating"
|
|
1097
|
+
err = git2svn.svn.parse_svn_error(stderr, "commit", self.path)
|
|
1098
|
+
self.assertIsInstance(err, git2svn.SvnOutOfDateError)
|
|
1099
|
+
self.assertIn("svn update", str(err))
|
|
1100
|
+
|
|
1101
|
+
@patch.object(git2svn.SvnWorkspace, "run_cmd")
|
|
1102
|
+
def test_commit_raises_lock_error_with_hints(self, mock_cmd):
|
|
1103
|
+
mock_cmd.return_value = subprocess.CompletedProcess(
|
|
1104
|
+
[], 1, stdout="", stderr="svn: E155004: Working copy locked; please run 'svn cleanup'"
|
|
1105
|
+
)
|
|
1106
|
+
with self.assertRaises(git2svn.SvnLockError) as cm:
|
|
1107
|
+
self.workspace.commit("test msg")
|
|
1108
|
+
self.assertIn("svn cleanup", str(cm.exception))
|
|
1109
|
+
|
|
1110
|
+
@patch.object(git2svn.SvnWorkspace, "run_cmd")
|
|
1111
|
+
def test_commit_raises_out_of_date_error_with_hints(self, mock_cmd):
|
|
1112
|
+
mock_cmd.return_value = subprocess.CompletedProcess(
|
|
1113
|
+
[], 1, stdout="", stderr="svn: E155015: Commit failed because item is out of date"
|
|
1114
|
+
)
|
|
1115
|
+
with self.assertRaises(git2svn.SvnOutOfDateError) as cm:
|
|
1116
|
+
self.workspace.commit("test msg")
|
|
1117
|
+
self.assertIn("svn update", str(cm.exception))
|
|
1118
|
+
|
|
1119
|
+
@patch.object(git2svn.SvnWorkspace, "run_cmd")
|
|
1120
|
+
def test_update_raises_lock_error(self, mock_cmd):
|
|
1121
|
+
mock_cmd.return_value = subprocess.CompletedProcess(
|
|
1122
|
+
[], 1, stdout="", stderr="svn: E155004: Working copy locked."
|
|
1123
|
+
)
|
|
1124
|
+
with self.assertRaises(git2svn.SvnLockError):
|
|
1125
|
+
self.workspace.update()
|
|
1126
|
+
|
|
1127
|
+
@patch.object(git2svn.SvnWorkspace, "run_cmd")
|
|
1128
|
+
def test_is_clean_raises_on_lock(self, mock_cmd):
|
|
1129
|
+
mock_cmd.return_value = subprocess.CompletedProcess(
|
|
1130
|
+
[], 1, stdout="", stderr="svn: E155004: Working copy locked."
|
|
1131
|
+
)
|
|
1132
|
+
with self.assertRaises(git2svn.SvnLockError):
|
|
1133
|
+
self.workspace.is_clean()
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
class TestStatus(unittest.TestCase):
|
|
1137
|
+
def setUp(self):
|
|
1138
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
1139
|
+
self.path = Path(self.temp_dir.name).resolve()
|
|
1140
|
+
self.git_path = self.path / "git_repo"
|
|
1141
|
+
self.svn_path = self.path / "svn_repo"
|
|
1142
|
+
self.git_path.mkdir()
|
|
1143
|
+
self.svn_path.mkdir()
|
|
1144
|
+
(self.svn_path / ".svn").mkdir()
|
|
1145
|
+
|
|
1146
|
+
subprocess.run(["git", "init"], cwd=self.git_path, check=True, capture_output=True)
|
|
1147
|
+
subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.git_path, check=True)
|
|
1148
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=self.git_path, check=True)
|
|
1149
|
+
|
|
1150
|
+
self.git_repo = git2svn.GitRepo(self.git_path)
|
|
1151
|
+
self.svn_workspace = git2svn.SvnWorkspace(self.svn_path)
|
|
1152
|
+
self.patcher = git2svn.Patcher(self.svn_path)
|
|
1153
|
+
self.sync_mgr = git2svn.Synchronizer(self.git_repo, self.svn_workspace, self.patcher)
|
|
1154
|
+
|
|
1155
|
+
def tearDown(self):
|
|
1156
|
+
self.temp_dir.cleanup()
|
|
1157
|
+
|
|
1158
|
+
def test_status_clean_no_commits(self):
|
|
1159
|
+
"""Status succeeds when both repos are clean and range has no pending commits."""
|
|
1160
|
+
f = self.git_path / "init.txt"
|
|
1161
|
+
f.write_text("hello\n")
|
|
1162
|
+
subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
|
|
1163
|
+
subprocess.run(["git", "commit", "-m", "init commit"], cwd=self.git_path, check=True)
|
|
1164
|
+
head = self.git_repo.get_head_commit()
|
|
1165
|
+
|
|
1166
|
+
self.git_repo.set_config("git2svn.defaultRange", f"{head}..{head}")
|
|
1167
|
+
|
|
1168
|
+
with (
|
|
1169
|
+
patch.object(self.svn_workspace, "is_clean", return_value=True),
|
|
1170
|
+
patch.object(self.svn_workspace, "get_info", return_value={"URL": "file:///svn/trunk", "Revision": "10"}),
|
|
1171
|
+
):
|
|
1172
|
+
code = self.sync_mgr.status()
|
|
1173
|
+
self.assertEqual(code, 0)
|
|
1174
|
+
|
|
1175
|
+
def test_status_pending_commits(self):
|
|
1176
|
+
"""Status succeeds and reports pending commits."""
|
|
1177
|
+
f = self.git_path / "base.txt"
|
|
1178
|
+
f.write_text("base\n")
|
|
1179
|
+
subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
|
|
1180
|
+
subprocess.run(["git", "commit", "-m", "base"], cwd=self.git_path, check=True)
|
|
1181
|
+
base = self.git_repo.get_head_commit()
|
|
1182
|
+
|
|
1183
|
+
f2 = self.git_path / "feat.txt"
|
|
1184
|
+
f2.write_text("feat\n")
|
|
1185
|
+
subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
|
|
1186
|
+
subprocess.run(["git", "commit", "-m", "feat: new feature"], cwd=self.git_path, check=True)
|
|
1187
|
+
head = self.git_repo.get_head_commit()
|
|
1188
|
+
|
|
1189
|
+
self.git_repo.set_config("git2svn.defaultRange", f"{base}..{head}")
|
|
1190
|
+
|
|
1191
|
+
with (
|
|
1192
|
+
patch.object(self.svn_workspace, "is_clean", return_value=True),
|
|
1193
|
+
patch.object(self.svn_workspace, "get_info", return_value={"URL": "file:///svn/trunk", "Revision": "10"}),
|
|
1194
|
+
):
|
|
1195
|
+
code = self.sync_mgr.status()
|
|
1196
|
+
self.assertEqual(code, 0)
|
|
1197
|
+
|
|
1198
|
+
def test_status_detects_merge_commits_as_error(self):
|
|
1199
|
+
"""Status exits with code 1 if merge commits exist in the range."""
|
|
1200
|
+
f = self.git_path / "base.txt"
|
|
1201
|
+
f.write_text("base\n")
|
|
1202
|
+
subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
|
|
1203
|
+
subprocess.run(["git", "commit", "-m", "base"], cwd=self.git_path, check=True)
|
|
1204
|
+
base = self.git_repo.get_head_commit()
|
|
1205
|
+
|
|
1206
|
+
init_branch = self.git_repo.get_current_branch()
|
|
1207
|
+
subprocess.run(["git", "checkout", "-b", "side"], cwd=self.git_path, check=True, capture_output=True)
|
|
1208
|
+
f_side = self.git_path / "side.txt"
|
|
1209
|
+
f_side.write_text("side\n")
|
|
1210
|
+
subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
|
|
1211
|
+
subprocess.run(["git", "commit", "-m", "side commit"], cwd=self.git_path, check=True)
|
|
1212
|
+
|
|
1213
|
+
subprocess.run(["git", "checkout", init_branch], cwd=self.git_path, check=True, capture_output=True)
|
|
1214
|
+
f_main = self.git_path / "main.txt"
|
|
1215
|
+
f_main.write_text("main\n")
|
|
1216
|
+
subprocess.run(["git", "add", "."], cwd=self.git_path, check=True)
|
|
1217
|
+
subprocess.run(["git", "commit", "-m", "main commit"], cwd=self.git_path, check=True)
|
|
1218
|
+
|
|
1219
|
+
# Create merge commit
|
|
1220
|
+
subprocess.run(["git", "merge", "--no-ff", "side", "-m", "merge side"], cwd=self.git_path, check=True)
|
|
1221
|
+
head = self.git_repo.get_head_commit()
|
|
1222
|
+
|
|
1223
|
+
self.git_repo.set_config("git2svn.defaultRange", f"{base}..{head}")
|
|
1224
|
+
|
|
1225
|
+
with (
|
|
1226
|
+
patch.object(self.svn_workspace, "is_clean", return_value=True),
|
|
1227
|
+
patch.object(self.svn_workspace, "get_info", return_value={"URL": "file:///svn/trunk", "Revision": "10"}),
|
|
1228
|
+
):
|
|
1229
|
+
code = self.sync_mgr.status()
|
|
1230
|
+
self.assertEqual(code, 1)
|
|
1231
|
+
|
|
1232
|
+
def test_status_detects_paused_replay_state(self):
|
|
1233
|
+
"""Status exits with code 1 when an in-progress replay is paused."""
|
|
1234
|
+
git2svn.save_replay_state(
|
|
1235
|
+
self.svn_path,
|
|
1236
|
+
{
|
|
1237
|
+
"state": "CONFLICT_PAUSED",
|
|
1238
|
+
"current_commit": "12345678",
|
|
1239
|
+
"current_commit_msg": "conflict commit",
|
|
1240
|
+
"remaining_commits": ["abcdef12"],
|
|
1241
|
+
"completed_commits": 1,
|
|
1242
|
+
"total_commits": 3,
|
|
1243
|
+
},
|
|
1244
|
+
)
|
|
1245
|
+
try:
|
|
1246
|
+
with (
|
|
1247
|
+
patch.object(self.svn_workspace, "is_clean", return_value=True),
|
|
1248
|
+
patch.object(
|
|
1249
|
+
self.svn_workspace, "get_info", return_value={"URL": "file:///svn/trunk", "Revision": "10"}
|
|
1250
|
+
),
|
|
1251
|
+
):
|
|
1252
|
+
code = self.sync_mgr.status()
|
|
1253
|
+
self.assertEqual(code, 1)
|
|
1254
|
+
finally:
|
|
1255
|
+
git2svn.clear_replay_state(self.svn_path)
|
|
1256
|
+
|
|
1257
|
+
def test_status_cli_dispatch(self):
|
|
1258
|
+
"""Verify git2svn status CLI command executes correctly."""
|
|
1259
|
+
self.git_repo.set_config("git2svn.svnDir", str(self.svn_path))
|
|
1260
|
+
with patch.object(git2svn.Synchronizer, "status", return_value=0) as mock_status:
|
|
1261
|
+
code = git2svn.main(["--git-dir", str(self.git_path), "status"])
|
|
1262
|
+
self.assertEqual(code, 0)
|
|
1263
|
+
mock_status.assert_called_once()
|
|
1264
|
+
|
|
1265
|
+
|
|
1043
1266
|
if __name__ == "__main__":
|
|
1044
1267
|
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
|