git2svn 0.4.0__py3-none-any.whl
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/__init__.py +56 -0
- git2svn/__main__.py +6 -0
- git2svn/cli.py +481 -0
- git2svn/core.py +485 -0
- git2svn/eol.py +67 -0
- git2svn/git.py +286 -0
- git2svn/patcher.py +58 -0
- git2svn/state.py +65 -0
- git2svn/svn.py +225 -0
- git2svn-0.4.0.dist-info/METADATA +148 -0
- git2svn-0.4.0.dist-info/RECORD +15 -0
- git2svn-0.4.0.dist-info/WHEEL +5 -0
- git2svn-0.4.0.dist-info/entry_points.txt +2 -0
- git2svn-0.4.0.dist-info/licenses/LICENSE.md +201 -0
- git2svn-0.4.0.dist-info/top_level.txt +1 -0
git2svn/git.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("git2svn")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_ref_arguments(
|
|
12
|
+
primary_ref: Optional[str], secondary_ref: Optional[str] = None
|
|
13
|
+
) -> Tuple[bool, str, Optional[str]]:
|
|
14
|
+
"""
|
|
15
|
+
Parses CLI ref arguments into either a single commit or a (start_ref, end_ref) range.
|
|
16
|
+
Returns: (is_single, start_or_commit, end_ref)
|
|
17
|
+
Examples:
|
|
18
|
+
'abc1234' -> (True, 'abc1234', None)
|
|
19
|
+
'main..feature' -> (False, 'main', 'feature')
|
|
20
|
+
'main', 'feature' -> (False, 'main', 'feature')
|
|
21
|
+
"""
|
|
22
|
+
if not primary_ref:
|
|
23
|
+
raise ValueError("A commit reference or range is required.")
|
|
24
|
+
|
|
25
|
+
if secondary_ref:
|
|
26
|
+
return False, primary_ref, secondary_ref
|
|
27
|
+
|
|
28
|
+
if ".." in primary_ref:
|
|
29
|
+
parts = primary_ref.split("..", 1)
|
|
30
|
+
return False, parts[0], parts[1]
|
|
31
|
+
|
|
32
|
+
return True, primary_ref, None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FileChange:
|
|
36
|
+
"""Represents a file change between two Git revisions."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, action: str, path: str, old_path: Optional[str] = None):
|
|
39
|
+
self.action = action.upper()
|
|
40
|
+
self.path = Path(path)
|
|
41
|
+
self.old_path = Path(old_path) if old_path else None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_added(self) -> bool:
|
|
45
|
+
return self.action.startswith("A")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_deleted(self) -> bool:
|
|
49
|
+
return self.action.startswith("D")
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_modified(self) -> bool:
|
|
53
|
+
return self.action.startswith("M")
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_renamed(self) -> bool:
|
|
57
|
+
return self.action.startswith("R")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_copied(self) -> bool:
|
|
61
|
+
return self.action.startswith("C")
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
if self.old_path:
|
|
65
|
+
return f"<FileChange {self.action}: {self.old_path} -> {self.path}>"
|
|
66
|
+
return f"<FileChange {self.action}: {self.path}>"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_name_status(status_output: str) -> List[FileChange]:
|
|
70
|
+
"""Parse standard tabular output of `git diff --name-status`."""
|
|
71
|
+
changes: List[FileChange] = []
|
|
72
|
+
for line in status_output.splitlines():
|
|
73
|
+
line = line.strip()
|
|
74
|
+
if not line:
|
|
75
|
+
continue
|
|
76
|
+
parts = line.split("\t")
|
|
77
|
+
if not parts:
|
|
78
|
+
continue
|
|
79
|
+
status_code = parts[0].strip()
|
|
80
|
+
if not status_code:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
action_char = status_code[0].upper()
|
|
84
|
+
if action_char in ("R", "C"):
|
|
85
|
+
if len(parts) >= 3:
|
|
86
|
+
old_p = parts[1].strip().strip('"')
|
|
87
|
+
new_p = parts[2].strip().strip('"')
|
|
88
|
+
changes.append(FileChange(action=action_char, path=new_p, old_path=old_p))
|
|
89
|
+
else:
|
|
90
|
+
logger.warning("Unrecognized rename/copy format: %s", line)
|
|
91
|
+
else:
|
|
92
|
+
if len(parts) >= 2:
|
|
93
|
+
p = parts[1].strip().strip('"')
|
|
94
|
+
changes.append(FileChange(action=action_char, path=p))
|
|
95
|
+
else:
|
|
96
|
+
logger.warning("Unrecognized status format: %s", line)
|
|
97
|
+
return changes
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_name_status_z(null_output: str) -> List[FileChange]:
|
|
101
|
+
"""Parse NUL-delimited output from `git diff -z --name-status`."""
|
|
102
|
+
changes: List[FileChange] = []
|
|
103
|
+
tokens = null_output.split("\0")
|
|
104
|
+
idx = 0
|
|
105
|
+
while idx < len(tokens):
|
|
106
|
+
token = tokens[idx].strip()
|
|
107
|
+
if not token:
|
|
108
|
+
idx += 1
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
action_char = token[0].upper()
|
|
112
|
+
if action_char in ("R", "C"):
|
|
113
|
+
if idx + 2 < len(tokens):
|
|
114
|
+
old_p = tokens[idx + 1]
|
|
115
|
+
new_p = tokens[idx + 2]
|
|
116
|
+
changes.append(FileChange(action=action_char, path=new_p, old_path=old_p))
|
|
117
|
+
idx += 3
|
|
118
|
+
else:
|
|
119
|
+
break
|
|
120
|
+
else:
|
|
121
|
+
if idx + 1 < len(tokens):
|
|
122
|
+
p = tokens[idx + 1]
|
|
123
|
+
changes.append(FileChange(action=action_char, path=p))
|
|
124
|
+
idx += 2
|
|
125
|
+
else:
|
|
126
|
+
break
|
|
127
|
+
return changes
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class GitRepo:
|
|
131
|
+
"""Wrapper around git commands for the source repository."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, repo_dir: Path, git_bin: str = "git"):
|
|
134
|
+
self.repo_dir = repo_dir.resolve()
|
|
135
|
+
self.git_bin = git_bin
|
|
136
|
+
|
|
137
|
+
def run_cmd(self, args: List[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
138
|
+
cmd = [self.git_bin] + args
|
|
139
|
+
logger.debug("Executing Git command in %s: %s", self.repo_dir, " ".join(cmd))
|
|
140
|
+
return subprocess.run(
|
|
141
|
+
cmd,
|
|
142
|
+
cwd=self.repo_dir,
|
|
143
|
+
check=check,
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
encoding="utf-8",
|
|
147
|
+
errors="replace",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def run_cmd_bytes(self, args: List[str], check: bool = True) -> subprocess.CompletedProcess[bytes]:
|
|
151
|
+
cmd = [self.git_bin] + args
|
|
152
|
+
logger.debug("Executing Git command (bytes) in %s: %s", self.repo_dir, " ".join(cmd))
|
|
153
|
+
return subprocess.run(
|
|
154
|
+
cmd,
|
|
155
|
+
cwd=self.repo_dir,
|
|
156
|
+
check=check,
|
|
157
|
+
capture_output=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def get_file_content_bytes(self, ref: str, path: Path) -> bytes:
|
|
161
|
+
"""Extract exact binary content of a file from Git's object database at revision ref."""
|
|
162
|
+
ref_path = f"{ref}:{path.as_posix()}"
|
|
163
|
+
res = self.run_cmd_bytes(["show", ref_path])
|
|
164
|
+
return res.stdout
|
|
165
|
+
|
|
166
|
+
def get_file_mode(self, ref: str, path: Path) -> Optional[str]:
|
|
167
|
+
"""Return the file mode (e.g. '100644', '100755', '120000') from git ls-tree."""
|
|
168
|
+
res = self.run_cmd(["ls-tree", ref, path.as_posix()], check=False)
|
|
169
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
170
|
+
parts = res.stdout.strip().split()
|
|
171
|
+
if parts:
|
|
172
|
+
return parts[0]
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def is_valid_repo(self) -> bool:
|
|
176
|
+
try:
|
|
177
|
+
res = self.run_cmd(["rev-parse", "--is-inside-work-tree"], check=False)
|
|
178
|
+
return res.returncode == 0 and res.stdout.strip() == "true"
|
|
179
|
+
except Exception:
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def get_current_branch(self) -> str:
|
|
183
|
+
"""Return the current branch name or 'HEAD (detached)'."""
|
|
184
|
+
res = self.run_cmd(["rev-parse", "--abbrev-ref", "HEAD"], check=False)
|
|
185
|
+
if res.returncode == 0:
|
|
186
|
+
branch = res.stdout.strip()
|
|
187
|
+
return branch if branch != "HEAD" else "HEAD (detached)"
|
|
188
|
+
return "unknown"
|
|
189
|
+
|
|
190
|
+
def get_head_commit(self) -> str:
|
|
191
|
+
"""Return the short hash of HEAD."""
|
|
192
|
+
res = self.run_cmd(["rev-parse", "--short", "HEAD"], check=False)
|
|
193
|
+
if res.returncode == 0:
|
|
194
|
+
return res.stdout.strip()
|
|
195
|
+
return "unknown"
|
|
196
|
+
|
|
197
|
+
def get_commit_parent(self, commit_hash: str) -> Optional[str]:
|
|
198
|
+
"""Return the parent commit hash, or None if root commit."""
|
|
199
|
+
res = self.run_cmd(["rev-parse", "--verify", f"{commit_hash}^"], check=False)
|
|
200
|
+
if res.returncode == 0:
|
|
201
|
+
return res.stdout.strip()
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
def get_diff(self, ref_spec: str) -> str:
|
|
205
|
+
"""Get unified diff for the given reference specification."""
|
|
206
|
+
res = self.run_cmd(["diff", "--binary", ref_spec])
|
|
207
|
+
return res.stdout
|
|
208
|
+
|
|
209
|
+
def get_diff_root(self, commit_hash: str) -> str:
|
|
210
|
+
"""Get unified diff for a root commit."""
|
|
211
|
+
empty_tree_hash = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
212
|
+
res = self.run_cmd(["diff", "--binary", empty_tree_hash, commit_hash])
|
|
213
|
+
return res.stdout
|
|
214
|
+
|
|
215
|
+
def get_name_status(self, ref_spec: str) -> List[FileChange]:
|
|
216
|
+
"""Get list of changed files with their status."""
|
|
217
|
+
res = self.run_cmd(["diff", "-z", "--name-status", ref_spec])
|
|
218
|
+
return parse_name_status_z(res.stdout)
|
|
219
|
+
|
|
220
|
+
def get_name_status_root(self, commit_hash: str) -> List[FileChange]:
|
|
221
|
+
"""Get list of changed files for a root commit."""
|
|
222
|
+
empty_tree_hash = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
223
|
+
res = self.run_cmd(["diff", "-z", "--name-status", empty_tree_hash, commit_hash])
|
|
224
|
+
return parse_name_status_z(res.stdout)
|
|
225
|
+
|
|
226
|
+
def get_commit_message(self, commit_hash: str) -> str:
|
|
227
|
+
"""Get the full commit message for the given commit hash."""
|
|
228
|
+
res = self.run_cmd(["log", "-1", "--format=%B", commit_hash])
|
|
229
|
+
return res.stdout.strip()
|
|
230
|
+
|
|
231
|
+
def get_commit_range(self, start_ref: str, end_ref: str) -> List[str]:
|
|
232
|
+
"""Return list of commit hashes in chronological order (start_ref..end_ref)."""
|
|
233
|
+
res = self.run_cmd(["rev-list", "--reverse", "--topo-order", f"{start_ref}..{end_ref}"])
|
|
234
|
+
return [line.strip() for line in res.stdout.splitlines() if line.strip()]
|
|
235
|
+
|
|
236
|
+
def get_merge_commits(self, start_ref: str, end_ref: str) -> List[str]:
|
|
237
|
+
"""Return list of merge commits in the range (start_ref..end_ref)."""
|
|
238
|
+
res = self.run_cmd(["rev-list", "--merges", f"{start_ref}..{end_ref}"])
|
|
239
|
+
return [line.strip() for line in res.stdout.splitlines() if line.strip()]
|
|
240
|
+
|
|
241
|
+
def get_tree_files(self, ref: str) -> List[Path]:
|
|
242
|
+
"""Return list of all relative file paths at revision ref (from git ls-tree -r -z)."""
|
|
243
|
+
res = self.run_cmd_bytes(["ls-tree", "-r", "-z", "--name-only", ref])
|
|
244
|
+
raw_paths = res.stdout.split(b"\0")
|
|
245
|
+
files: List[Path] = []
|
|
246
|
+
for raw in raw_paths:
|
|
247
|
+
if not raw:
|
|
248
|
+
continue
|
|
249
|
+
path_str = raw.decode("utf-8", errors="replace")
|
|
250
|
+
files.append(Path(path_str))
|
|
251
|
+
return files
|
|
252
|
+
|
|
253
|
+
def get_config(self, key: str) -> Optional[str]:
|
|
254
|
+
"""Read a configuration value from git config (local repository or global)."""
|
|
255
|
+
res = self.run_cmd(["config", "--get", key], check=False)
|
|
256
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
257
|
+
return res.stdout.strip()
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
def get_config_bool(self, key: str) -> Optional[bool]:
|
|
261
|
+
"""Read a boolean configuration value from git config (--bool)."""
|
|
262
|
+
res = self.run_cmd(["config", "--bool", "--get", key], check=False)
|
|
263
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
264
|
+
val = res.stdout.strip().lower()
|
|
265
|
+
return val in ("true", "yes", "on", "1")
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
def set_config(self, key: str, value: str) -> None:
|
|
269
|
+
"""Set a repository-level configuration value via git config <key> <value>."""
|
|
270
|
+
self.run_cmd(["config", key, value], check=True)
|
|
271
|
+
|
|
272
|
+
def get_local_branches(self) -> List[str]:
|
|
273
|
+
"""Return list of local branch names."""
|
|
274
|
+
res = self.run_cmd(["for-each-ref", "--format=%(refname:short)", "refs/heads/"], check=False)
|
|
275
|
+
if res.returncode == 0:
|
|
276
|
+
return [line.strip() for line in res.stdout.splitlines() if line.strip()]
|
|
277
|
+
return []
|
|
278
|
+
|
|
279
|
+
def get_remote_branches(self) -> List[str]:
|
|
280
|
+
"""Return list of remote branch names (e.g. 'origin/trunk', 'svn-mirror/trunk')."""
|
|
281
|
+
res = self.run_cmd(["for-each-ref", "--format=%(refname:short)", "refs/remotes/"], check=False)
|
|
282
|
+
if res.returncode == 0:
|
|
283
|
+
return [
|
|
284
|
+
line.strip() for line in res.stdout.splitlines() if line.strip() and not line.strip().endswith("/HEAD")
|
|
285
|
+
]
|
|
286
|
+
return []
|
git2svn/patcher.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("git2svn")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Patcher:
|
|
12
|
+
"""Wrapper to apply diffs using git apply."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, target_dir: Path, git_bin: str = "git", dry_run: bool = False):
|
|
15
|
+
self.target_dir = target_dir.resolve()
|
|
16
|
+
self.git_bin = git_bin
|
|
17
|
+
self.dry_run = dry_run
|
|
18
|
+
|
|
19
|
+
def apply_diff(self, diff_content: str) -> None:
|
|
20
|
+
"""Apply unified diff text to target_dir using git apply."""
|
|
21
|
+
if not diff_content.strip():
|
|
22
|
+
logger.info("Diff is empty. Nothing to patch.")
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
cmd = [self.git_bin, "apply", "--ignore-whitespace", "--unsafe-paths", "--reject"]
|
|
26
|
+
if self.dry_run:
|
|
27
|
+
print(f"[DRY-RUN] (in {self.target_dir}) {' '.join(cmd)} << EOF\n{diff_content.strip()[:200]}...\nEOF")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
logger.debug("Applying patch to %s using %s", self.target_dir, " ".join(cmd))
|
|
31
|
+
proc = subprocess.run(
|
|
32
|
+
cmd,
|
|
33
|
+
cwd=self.target_dir,
|
|
34
|
+
input=diff_content,
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
encoding="utf-8",
|
|
38
|
+
errors="replace",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if proc.returncode != 0:
|
|
42
|
+
logger.error(
|
|
43
|
+
"git apply failed with code %d:\nSTDOUT:\n%s\nSTDERR:\n%s", proc.returncode, proc.stdout, proc.stderr
|
|
44
|
+
)
|
|
45
|
+
print(f"Error: git apply failed (exit code {proc.returncode}).", file=sys.stderr)
|
|
46
|
+
if proc.stdout:
|
|
47
|
+
print(proc.stdout, file=sys.stderr)
|
|
48
|
+
if proc.stderr:
|
|
49
|
+
print(proc.stderr, file=sys.stderr)
|
|
50
|
+
print(
|
|
51
|
+
"Tip: Check for .rej reject files in the SVN workspace or use 'git2svn stage --copy' to copy files directly.",
|
|
52
|
+
file=sys.stderr,
|
|
53
|
+
)
|
|
54
|
+
raise subprocess.CalledProcessError(proc.returncode, cmd, proc.stdout, proc.stderr)
|
|
55
|
+
else:
|
|
56
|
+
if proc.stdout or proc.stderr:
|
|
57
|
+
msg = (proc.stdout + "\n" + proc.stderr).strip()
|
|
58
|
+
logger.info("Patch applied successfully:\n%s", msg)
|
git2svn/state.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("git2svn")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_replay_state_path(workspace_dir: Path) -> Path:
|
|
13
|
+
"""Get the path to the replay state file inside .svn metadata or workspace root."""
|
|
14
|
+
svn_meta = workspace_dir / ".svn"
|
|
15
|
+
if svn_meta.is_dir():
|
|
16
|
+
return svn_meta / "git2svn-replay.json"
|
|
17
|
+
return workspace_dir / ".git2svn-replay.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save_replay_state(workspace_dir: Path, data: Dict[str, Any]) -> None:
|
|
21
|
+
"""Save replay state to disk."""
|
|
22
|
+
path = get_replay_state_path(workspace_dir)
|
|
23
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_replay_state(workspace_dir: Path) -> Optional[Dict[str, Any]]:
|
|
27
|
+
"""Load replay state from disk if it exists."""
|
|
28
|
+
path = get_replay_state_path(workspace_dir)
|
|
29
|
+
if path.is_file():
|
|
30
|
+
try:
|
|
31
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.error("Failed to parse replay state file: %s", e)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def clear_replay_state(workspace_dir: Path) -> None:
|
|
38
|
+
"""Remove the replay state file if it exists."""
|
|
39
|
+
path = get_replay_state_path(workspace_dir)
|
|
40
|
+
if path.is_file():
|
|
41
|
+
path.unlink(missing_ok=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_conflict_artifacts(workspace_dir: Path) -> List[Path]:
|
|
45
|
+
"""Find all .rej and .orig files in workspace_dir (ignoring .svn)."""
|
|
46
|
+
artifacts: List[Path] = []
|
|
47
|
+
for root, dirs, files in os.walk(workspace_dir):
|
|
48
|
+
if ".svn" in dirs:
|
|
49
|
+
dirs.remove(".svn")
|
|
50
|
+
for f in files:
|
|
51
|
+
if f.endswith(".rej") or f.endswith(".orig"):
|
|
52
|
+
artifacts.append(Path(root) / f)
|
|
53
|
+
return artifacts
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def clean_conflict_artifacts(workspace_dir: Path) -> List[Path]:
|
|
57
|
+
"""Remove all .rej and .orig files in workspace_dir."""
|
|
58
|
+
artifacts = find_conflict_artifacts(workspace_dir)
|
|
59
|
+
for a in artifacts:
|
|
60
|
+
try:
|
|
61
|
+
a.unlink(missing_ok=True)
|
|
62
|
+
logger.debug("Removed conflict artifact: %s", a)
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
return artifacts
|
git2svn/svn.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from .git import FileChange
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("git2svn")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_svn_binary() -> str:
|
|
18
|
+
"""Find svn executable, checking TortoiseSVN / SlikSVN default paths if on Windows."""
|
|
19
|
+
found = shutil.which("svn")
|
|
20
|
+
if found:
|
|
21
|
+
return found
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
candidates = [
|
|
24
|
+
Path("C:/Program Files/TortoiseSVN/bin/svn.exe"),
|
|
25
|
+
Path("C:/Program Files (x86)/TortoiseSVN/bin/svn.exe"),
|
|
26
|
+
Path("C:/Program Files/SlikSvn/bin/svn.exe"),
|
|
27
|
+
Path("C:/Program Files (x86)/SlikSvn/bin/svn.exe"),
|
|
28
|
+
]
|
|
29
|
+
for c in candidates:
|
|
30
|
+
if c.is_file():
|
|
31
|
+
return str(c)
|
|
32
|
+
return "svn"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SvnWorkspace:
|
|
36
|
+
"""Wrapper around SVN commands and filesystem staging operations."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, workspace_dir: Path, svn_bin: Optional[str] = None, dry_run: bool = False):
|
|
39
|
+
self.workspace_dir = workspace_dir.resolve()
|
|
40
|
+
self.svn_bin = svn_bin or find_svn_binary()
|
|
41
|
+
self.dry_run = dry_run
|
|
42
|
+
|
|
43
|
+
def run_cmd(self, args: List[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
44
|
+
cmd = [self.svn_bin] + args
|
|
45
|
+
if self.dry_run:
|
|
46
|
+
print(f"[DRY-RUN] (in {self.workspace_dir}) {' '.join(cmd)}")
|
|
47
|
+
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
|
48
|
+
|
|
49
|
+
logger.debug("Executing SVN command in %s: %s", self.workspace_dir, " ".join(cmd))
|
|
50
|
+
return subprocess.run(
|
|
51
|
+
cmd,
|
|
52
|
+
cwd=self.workspace_dir,
|
|
53
|
+
check=check,
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
encoding="utf-8",
|
|
57
|
+
errors="replace",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def is_valid_workspace(self) -> bool:
|
|
61
|
+
"""Check if workspace directory exists and has .svn or svn info."""
|
|
62
|
+
if not self.workspace_dir.is_dir():
|
|
63
|
+
return False
|
|
64
|
+
if (self.workspace_dir / ".svn").exists():
|
|
65
|
+
return True
|
|
66
|
+
try:
|
|
67
|
+
res = self.run_cmd(["info"], check=False)
|
|
68
|
+
return res.returncode == 0
|
|
69
|
+
except Exception:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def get_info(self) -> Dict[str, str]:
|
|
73
|
+
"""Return key-value mapping of 'svn info' output."""
|
|
74
|
+
info: Dict[str, str] = {}
|
|
75
|
+
res = self.run_cmd(["info"], check=False)
|
|
76
|
+
if res.returncode == 0:
|
|
77
|
+
for line in res.stdout.splitlines():
|
|
78
|
+
if ":" in line:
|
|
79
|
+
k, v = line.split(":", 1)
|
|
80
|
+
info[k.strip()] = v.strip()
|
|
81
|
+
return info
|
|
82
|
+
|
|
83
|
+
def is_clean(self) -> bool:
|
|
84
|
+
"""Check if SVN workspace has no uncommitted changes."""
|
|
85
|
+
res = self.run_cmd(["status", "-q"], check=False)
|
|
86
|
+
return not bool(res.stdout.strip())
|
|
87
|
+
|
|
88
|
+
def get_versioned_files(self) -> List[Path]:
|
|
89
|
+
"""
|
|
90
|
+
Return list of all versioned file paths in the SVN workspace (relative to workspace_dir).
|
|
91
|
+
Queries 'svn status -v -q --depth infinity' (ignores unversioned files and directories).
|
|
92
|
+
Falls back to filesystem scan (excluding .svn) if svn command fails or workspace is empty.
|
|
93
|
+
"""
|
|
94
|
+
files: List[Path] = []
|
|
95
|
+
res = self.run_cmd(["status", "-v", "-q", "--depth", "infinity"], check=False)
|
|
96
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
97
|
+
for line in res.stdout.splitlines():
|
|
98
|
+
parts = line.strip().split()
|
|
99
|
+
if not parts:
|
|
100
|
+
continue
|
|
101
|
+
target_str = parts[-1]
|
|
102
|
+
target_path = Path(target_str)
|
|
103
|
+
if target_path.is_absolute():
|
|
104
|
+
try:
|
|
105
|
+
rel = target_path.relative_to(self.workspace_dir)
|
|
106
|
+
except ValueError:
|
|
107
|
+
continue
|
|
108
|
+
else:
|
|
109
|
+
rel = target_path
|
|
110
|
+
|
|
111
|
+
full_path = self.workspace_dir / rel
|
|
112
|
+
if str(rel) != "." and (full_path.is_file() or full_path.is_symlink() or not full_path.exists()):
|
|
113
|
+
files.append(rel)
|
|
114
|
+
return files
|
|
115
|
+
|
|
116
|
+
# Filesystem fallback if not in a working copy or status was empty
|
|
117
|
+
for root, dirs, f_list in os.walk(self.workspace_dir):
|
|
118
|
+
if ".svn" in dirs:
|
|
119
|
+
dirs.remove(".svn")
|
|
120
|
+
for f in f_list:
|
|
121
|
+
full = Path(root) / f
|
|
122
|
+
try:
|
|
123
|
+
files.append(full.relative_to(self.workspace_dir))
|
|
124
|
+
except ValueError:
|
|
125
|
+
pass
|
|
126
|
+
return files
|
|
127
|
+
|
|
128
|
+
def revert_all(self) -> None:
|
|
129
|
+
"""Revert all uncommitted changes in the workspace."""
|
|
130
|
+
logger.info("Reverting SVN workspace changes...")
|
|
131
|
+
self.run_cmd(["revert", "-R", "."], check=False)
|
|
132
|
+
|
|
133
|
+
def stage_add(self, rel_path: Path) -> None:
|
|
134
|
+
"""Run svn add <filepath> --parents. Creates parent directories if needed."""
|
|
135
|
+
target_path = self.workspace_dir / rel_path
|
|
136
|
+
if not self.dry_run:
|
|
137
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
else:
|
|
139
|
+
print(f"[DRY-RUN] Ensure parent directory exists: {target_path.parent}")
|
|
140
|
+
|
|
141
|
+
posix_path = rel_path.as_posix()
|
|
142
|
+
res = self.run_cmd(["add", posix_path, "--parents"], check=False)
|
|
143
|
+
if res.returncode != 0:
|
|
144
|
+
if "is already under version control" not in res.stderr and "already exists" not in res.stderr:
|
|
145
|
+
logger.error("Failed to 'svn add %s': %s", posix_path, res.stderr.strip())
|
|
146
|
+
raise subprocess.CalledProcessError(
|
|
147
|
+
res.returncode, [self.svn_bin, "add", posix_path], res.stdout, res.stderr
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def stage_rm(self, rel_path: Path) -> None:
|
|
151
|
+
"""Run svn rm <filepath>."""
|
|
152
|
+
posix_path = rel_path.as_posix()
|
|
153
|
+
res = self.run_cmd(["rm", posix_path], check=False)
|
|
154
|
+
if res.returncode != 0:
|
|
155
|
+
if "is not under version control" in res.stderr:
|
|
156
|
+
logger.warning("File %s not under SVN control to remove.", posix_path)
|
|
157
|
+
else:
|
|
158
|
+
logger.error("Failed to 'svn rm %s': %s", posix_path, res.stderr.strip())
|
|
159
|
+
raise subprocess.CalledProcessError(
|
|
160
|
+
res.returncode, [self.svn_bin, "rm", posix_path], res.stdout, res.stderr
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def commit(self, message: str) -> None:
|
|
164
|
+
"""Run svn commit using a temporary file with -F to support arbitrary message lengths and encodings."""
|
|
165
|
+
if self.dry_run:
|
|
166
|
+
print(f"[DRY-RUN] (in {self.workspace_dir}) {self.svn_bin} commit -F <msg_file>")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
logger.info("Executing svn commit in %s...", self.workspace_dir)
|
|
170
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as tf:
|
|
171
|
+
tf.write(message)
|
|
172
|
+
tf_path = Path(tf.name)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
res = self.run_cmd(["commit", "-F", str(tf_path)], check=False)
|
|
176
|
+
if res.returncode != 0:
|
|
177
|
+
logger.error("Failed to 'svn commit': %s", res.stderr.strip())
|
|
178
|
+
raise subprocess.CalledProcessError(
|
|
179
|
+
res.returncode, [self.svn_bin, "commit", "-F", str(tf_path)], res.stdout, res.stderr
|
|
180
|
+
)
|
|
181
|
+
if res.stdout:
|
|
182
|
+
logger.info("SVN commit output:\n%s", res.stdout.strip())
|
|
183
|
+
finally:
|
|
184
|
+
tf_path.unlink(missing_ok=True)
|
|
185
|
+
|
|
186
|
+
def apply_structural_changes(self, changes: List[FileChange]) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Execute corresponding SVN commands for file changes:
|
|
189
|
+
- Added (A): Run svn add <filepath> --parents
|
|
190
|
+
- Deleted (D): Run svn rm <filepath>
|
|
191
|
+
- Modified (M): No SVN structural command needed
|
|
192
|
+
- Renamed (R): svn rm <old_name> and svn add <new_name> --parents
|
|
193
|
+
- Copied (C): svn add <new_name> --parents
|
|
194
|
+
"""
|
|
195
|
+
for change in changes:
|
|
196
|
+
if change.is_deleted:
|
|
197
|
+
logger.info("SVN staging delete: %s", change.path)
|
|
198
|
+
self.stage_rm(change.path)
|
|
199
|
+
elif change.is_renamed:
|
|
200
|
+
assert change.old_path is not None
|
|
201
|
+
logger.info("SVN staging rename: %s -> %s", change.old_path, change.path)
|
|
202
|
+
self.stage_rm(change.old_path)
|
|
203
|
+
self.stage_add(change.path)
|
|
204
|
+
elif change.is_copied:
|
|
205
|
+
logger.info("SVN staging copied file: %s", change.path)
|
|
206
|
+
self.stage_add(change.path)
|
|
207
|
+
elif change.is_added:
|
|
208
|
+
logger.info("SVN staging add: %s", change.path)
|
|
209
|
+
self.stage_add(change.path)
|
|
210
|
+
elif change.is_modified:
|
|
211
|
+
logger.debug("Modified file requires no SVN structural command: %s", change.path)
|
|
212
|
+
|
|
213
|
+
def update(self) -> None:
|
|
214
|
+
"""Run svn update on the working copy to bump the working base revision to HEAD."""
|
|
215
|
+
if self.dry_run:
|
|
216
|
+
print(f"[DRY-RUN] (in {self.workspace_dir}) {self.svn_bin} update")
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
logger.info("Executing svn update in %s...", self.workspace_dir)
|
|
220
|
+
res = self.run_cmd(["update"], check=False)
|
|
221
|
+
if res.returncode != 0:
|
|
222
|
+
logger.error("Failed to 'svn update': %s", res.stderr.strip())
|
|
223
|
+
raise subprocess.CalledProcessError(res.returncode, [self.svn_bin, "update"], res.stdout, res.stderr)
|
|
224
|
+
if res.stdout:
|
|
225
|
+
logger.info("SVN update output:\n%s", res.stdout.strip())
|