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/core.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from .eol import detect_file_eol, normalize_file_eol
|
|
11
|
+
from .git import FileChange, GitRepo, parse_ref_arguments
|
|
12
|
+
from .patcher import Patcher
|
|
13
|
+
from .state import (
|
|
14
|
+
clean_conflict_artifacts,
|
|
15
|
+
clear_replay_state,
|
|
16
|
+
find_conflict_artifacts,
|
|
17
|
+
load_replay_state,
|
|
18
|
+
save_replay_state,
|
|
19
|
+
)
|
|
20
|
+
from .svn import SvnWorkspace
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("git2svn")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Synchronizer:
|
|
26
|
+
"""Coordinates Git extraction, patch/copy operations, and SVN staging."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
git_repo: GitRepo,
|
|
31
|
+
svn_workspace: SvnWorkspace,
|
|
32
|
+
patcher: Patcher,
|
|
33
|
+
dry_run: bool = False,
|
|
34
|
+
auto_update: bool = False,
|
|
35
|
+
):
|
|
36
|
+
self.git = git_repo
|
|
37
|
+
self.svn = svn_workspace
|
|
38
|
+
self.patcher = patcher
|
|
39
|
+
self.dry_run = dry_run
|
|
40
|
+
self.auto_update = auto_update
|
|
41
|
+
|
|
42
|
+
def show_identity_banner(self, target_ref_spec: str) -> None:
|
|
43
|
+
"""Display identity banner showing active Git and SVN target branches/URLs."""
|
|
44
|
+
branch = self.git.get_current_branch()
|
|
45
|
+
head = self.git.get_head_commit()
|
|
46
|
+
svn_info = self.svn.get_info()
|
|
47
|
+
|
|
48
|
+
svn_target = svn_info.get("Relative URL") or svn_info.get("URL") or str(self.svn.workspace_dir.name)
|
|
49
|
+
svn_rev = svn_info.get("Revision")
|
|
50
|
+
svn_suffix = f" (r{svn_rev})" if svn_rev else ""
|
|
51
|
+
|
|
52
|
+
banner = (
|
|
53
|
+
f"[TARGET] Git source : {self.git.repo_dir.name} [{branch} @ {head}] -> ref: {target_ref_spec}\n"
|
|
54
|
+
f"[TARGET] SVN target : {self.svn.workspace_dir.name} [{svn_target}{svn_suffix}]"
|
|
55
|
+
)
|
|
56
|
+
print(banner)
|
|
57
|
+
|
|
58
|
+
def stage(
|
|
59
|
+
self,
|
|
60
|
+
ref1: str,
|
|
61
|
+
ref2: Optional[str] = None,
|
|
62
|
+
use_copy: bool = False,
|
|
63
|
+
snapshot: bool = False,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Stage changes from a commit or range in SVN workspace without committing.
|
|
67
|
+
If snapshot=True, aligns the SVN workspace to match ref1 exactly (bypassing history).
|
|
68
|
+
If use_copy=True, brute-force copies files using Git object DB (bypassing patch).
|
|
69
|
+
"""
|
|
70
|
+
if snapshot:
|
|
71
|
+
self.stage_snapshot(ref1)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
is_single, start_or_commit, end_ref = parse_ref_arguments(ref1, ref2)
|
|
75
|
+
target_spec = f"{start_or_commit}..{end_ref}" if not is_single else start_or_commit
|
|
76
|
+
self.show_identity_banner(target_spec)
|
|
77
|
+
|
|
78
|
+
if is_single:
|
|
79
|
+
commit_hash = start_or_commit
|
|
80
|
+
logger.info("Staging single commit %s (copy_mode=%s)", commit_hash, use_copy)
|
|
81
|
+
if use_copy:
|
|
82
|
+
parent = self.git.get_commit_parent(commit_hash)
|
|
83
|
+
base_ref = parent if parent else "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
84
|
+
self._copy_and_stage_range(base_ref, commit_hash)
|
|
85
|
+
else:
|
|
86
|
+
self._patch_and_stage_commit(commit_hash)
|
|
87
|
+
else:
|
|
88
|
+
start_ref = start_or_commit
|
|
89
|
+
assert end_ref is not None
|
|
90
|
+
logger.info("Staging range %s..%s (copy_mode=%s)", start_ref, end_ref, use_copy)
|
|
91
|
+
if use_copy:
|
|
92
|
+
self._copy_and_stage_range(start_ref, end_ref)
|
|
93
|
+
else:
|
|
94
|
+
self._patch_and_stage_range(start_ref, end_ref)
|
|
95
|
+
logger.info("Changes staged successfully in SVN workspace (uncommitted).")
|
|
96
|
+
|
|
97
|
+
def stage_snapshot(self, target_ref: str) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Mirror the exact tree state of target_ref onto the SVN workspace without committing.
|
|
100
|
+
Detects added, deleted, and modified files by comparing the Git tree against SVN files.
|
|
101
|
+
"""
|
|
102
|
+
self.show_identity_banner(f"{target_ref} (snapshot)")
|
|
103
|
+
logger.info("Starting snapshot synchronization to Git ref '%s'...", target_ref)
|
|
104
|
+
|
|
105
|
+
git_files_list = self.git.get_tree_files(target_ref)
|
|
106
|
+
git_files_set = set(git_files_list)
|
|
107
|
+
svn_files_list = self.svn.get_versioned_files()
|
|
108
|
+
svn_files_set = set(svn_files_list)
|
|
109
|
+
|
|
110
|
+
deleted_files = sorted(svn_files_set - git_files_set)
|
|
111
|
+
added_files = sorted(git_files_set - svn_files_set)
|
|
112
|
+
common_files = sorted(git_files_set & svn_files_set)
|
|
113
|
+
|
|
114
|
+
logger.info(
|
|
115
|
+
"Snapshot delta: %d added, %d deleted, %d existing files to compare",
|
|
116
|
+
len(added_files),
|
|
117
|
+
len(deleted_files),
|
|
118
|
+
len(common_files),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# 1. Handle deleted files: remove from SVN and disk
|
|
122
|
+
for rel_path in deleted_files:
|
|
123
|
+
logger.info("SVN staging snapshot delete: %s", rel_path)
|
|
124
|
+
self.svn.stage_rm(rel_path)
|
|
125
|
+
full_path = self.svn.workspace_dir / rel_path
|
|
126
|
+
if not self.dry_run and full_path.exists():
|
|
127
|
+
if full_path.is_dir():
|
|
128
|
+
shutil.rmtree(full_path)
|
|
129
|
+
else:
|
|
130
|
+
full_path.unlink()
|
|
131
|
+
|
|
132
|
+
# 2. Handle modified files: compare content and write if changed
|
|
133
|
+
modified_count = 0
|
|
134
|
+
for rel_path in common_files:
|
|
135
|
+
dst_path = self.svn.workspace_dir / rel_path
|
|
136
|
+
mode = self.git.get_file_mode(target_ref, rel_path)
|
|
137
|
+
content = self.git.get_file_content_bytes(target_ref, rel_path)
|
|
138
|
+
|
|
139
|
+
if mode == "120000":
|
|
140
|
+
# Symlink
|
|
141
|
+
link_target = content.decode("utf-8", errors="replace").strip()
|
|
142
|
+
needs_update = True
|
|
143
|
+
if dst_path.is_symlink() and os.readlink(dst_path) == link_target:
|
|
144
|
+
needs_update = False
|
|
145
|
+
|
|
146
|
+
if needs_update:
|
|
147
|
+
modified_count += 1
|
|
148
|
+
if self.dry_run:
|
|
149
|
+
print(f"[DRY-RUN] Update symlink {rel_path} -> {link_target}")
|
|
150
|
+
else:
|
|
151
|
+
dst_path.unlink(missing_ok=True)
|
|
152
|
+
os.symlink(link_target, dst_path)
|
|
153
|
+
else:
|
|
154
|
+
# Regular file: check existing newline style & compare bytes
|
|
155
|
+
orig_eol = detect_file_eol(dst_path) if dst_path.exists() else None
|
|
156
|
+
# Normalize new content in memory to match orig_eol before comparing
|
|
157
|
+
target_bytes = content
|
|
158
|
+
if orig_eol:
|
|
159
|
+
unified = content.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
160
|
+
if orig_eol == b"\r\n":
|
|
161
|
+
target_bytes = unified.replace(b"\n", b"\r\n")
|
|
162
|
+
else:
|
|
163
|
+
target_bytes = unified
|
|
164
|
+
|
|
165
|
+
current_bytes = dst_path.read_bytes() if dst_path.exists() and dst_path.is_file() else None
|
|
166
|
+
if current_bytes != target_bytes:
|
|
167
|
+
modified_count += 1
|
|
168
|
+
if self.dry_run:
|
|
169
|
+
print(f"[DRY-RUN] Update file content: {rel_path}")
|
|
170
|
+
else:
|
|
171
|
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
if dst_path.exists() or dst_path.is_symlink():
|
|
173
|
+
dst_path.unlink()
|
|
174
|
+
dst_path.write_bytes(target_bytes)
|
|
175
|
+
|
|
176
|
+
# 3. Handle added files: extract from Git and run svn add
|
|
177
|
+
for rel_path in added_files:
|
|
178
|
+
logger.info("SVN staging snapshot add: %s", rel_path)
|
|
179
|
+
dst_path = self.svn.workspace_dir / rel_path
|
|
180
|
+
mode = self.git.get_file_mode(target_ref, rel_path)
|
|
181
|
+
content = self.git.get_file_content_bytes(target_ref, rel_path)
|
|
182
|
+
|
|
183
|
+
if self.dry_run:
|
|
184
|
+
print(f"[DRY-RUN] Extract new file {rel_path} and stage add")
|
|
185
|
+
else:
|
|
186
|
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
if dst_path.exists() or dst_path.is_symlink():
|
|
188
|
+
dst_path.unlink()
|
|
189
|
+
|
|
190
|
+
if mode == "120000":
|
|
191
|
+
link_target = content.decode("utf-8", errors="replace").strip()
|
|
192
|
+
os.symlink(link_target, dst_path)
|
|
193
|
+
else:
|
|
194
|
+
dst_path.write_bytes(content)
|
|
195
|
+
normalize_file_eol(dst_path)
|
|
196
|
+
|
|
197
|
+
self.svn.stage_add(rel_path)
|
|
198
|
+
|
|
199
|
+
logger.info(
|
|
200
|
+
"Snapshot staging completed: %d added, %d deleted, %d modified (uncommitted).",
|
|
201
|
+
len(added_files),
|
|
202
|
+
len(deleted_files),
|
|
203
|
+
modified_count,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def replay(self, ref1: str, ref2: Optional[str] = None) -> None:
|
|
207
|
+
"""
|
|
208
|
+
Replay a single commit or range of commits onto SVN, committing each with its Git message.
|
|
209
|
+
"""
|
|
210
|
+
is_single, start_or_commit, end_ref = parse_ref_arguments(ref1, ref2)
|
|
211
|
+
target_spec = f"{start_or_commit}..{end_ref}" if not is_single else start_or_commit
|
|
212
|
+
self.show_identity_banner(target_spec)
|
|
213
|
+
|
|
214
|
+
if is_single:
|
|
215
|
+
commit_hash = start_or_commit
|
|
216
|
+
logger.info("Replaying single commit %s", commit_hash)
|
|
217
|
+
self._execute_replay_queue([commit_hash], total_commits=1, start_index=1)
|
|
218
|
+
else:
|
|
219
|
+
start_ref = start_or_commit
|
|
220
|
+
assert end_ref is not None
|
|
221
|
+
|
|
222
|
+
if not self.dry_run and not self.svn.is_clean():
|
|
223
|
+
raise RuntimeError(
|
|
224
|
+
"SVN workspace has uncommitted changes. Please commit, stash, or revert them before starting a replay."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
merges = self.git.get_merge_commits(start_ref, end_ref)
|
|
228
|
+
if merges:
|
|
229
|
+
raise RuntimeError(
|
|
230
|
+
f"Range {start_ref}..{end_ref} contains {len(merges)} merge commit(s). "
|
|
231
|
+
"Replay requires a linear history (fast-forward only). Please rebase your branch first."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
commits = self.git.get_commit_range(start_ref, end_ref)
|
|
235
|
+
if not commits:
|
|
236
|
+
logger.info("No commits found in range %s..%s.", start_ref, end_ref)
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
logger.info("Starting replay of %d commit(s) from %s to %s...", len(commits), start_ref, end_ref)
|
|
240
|
+
self._execute_replay_queue(commits, total_commits=len(commits), start_index=1)
|
|
241
|
+
|
|
242
|
+
def replay_continue(self) -> None:
|
|
243
|
+
"""Resume an interrupted replay after user resolves conflicts."""
|
|
244
|
+
state = load_replay_state(self.svn.workspace_dir)
|
|
245
|
+
if not state:
|
|
246
|
+
raise RuntimeError("No replay in progress. Nothing to continue.")
|
|
247
|
+
|
|
248
|
+
current_commit = state["current_commit"]
|
|
249
|
+
current_msg = state["current_commit_msg"]
|
|
250
|
+
remaining = state.get("remaining_commits", [])
|
|
251
|
+
total = state.get("total_commits", len(remaining) + 1)
|
|
252
|
+
completed = state.get("completed_commits", 0)
|
|
253
|
+
|
|
254
|
+
# 1. Check for leftover .rej / .orig files
|
|
255
|
+
rej_files = find_conflict_artifacts(self.svn.workspace_dir)
|
|
256
|
+
if rej_files:
|
|
257
|
+
rel_rejs = [str(r.relative_to(self.svn.workspace_dir)) for r in rej_files]
|
|
258
|
+
raise RuntimeError(
|
|
259
|
+
f"Found rejected patch artifacts ({', '.join(rel_rejs)}). "
|
|
260
|
+
"Please resolve conflicts and delete .rej / .orig files before running --continue."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# 2. Commit the resolved changes for the interrupted commit
|
|
264
|
+
if not self.svn.is_clean():
|
|
265
|
+
logger.info("Committing resolved commit %s to SVN...", current_commit)
|
|
266
|
+
self.svn.commit(current_msg)
|
|
267
|
+
else:
|
|
268
|
+
logger.info(
|
|
269
|
+
"No changes in SVN workspace to commit for %s (commit resolved as empty or skipped).", current_commit
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
completed += 1
|
|
273
|
+
logger.info("Commit %s (%d/%d) resolved and committed.", current_commit, completed, total)
|
|
274
|
+
|
|
275
|
+
# 3. Resume remaining queue
|
|
276
|
+
if remaining:
|
|
277
|
+
self._execute_replay_queue(remaining, total_commits=total, start_index=completed + 1)
|
|
278
|
+
else:
|
|
279
|
+
clear_replay_state(self.svn.workspace_dir)
|
|
280
|
+
logger.info("Replay completed successfully! All %d commits applied.", total)
|
|
281
|
+
if self.auto_update:
|
|
282
|
+
self.svn.update()
|
|
283
|
+
|
|
284
|
+
def replay_abort(self) -> None:
|
|
285
|
+
"""Abort in-progress replay and revert uncommitted changes."""
|
|
286
|
+
state = load_replay_state(self.svn.workspace_dir)
|
|
287
|
+
if not state:
|
|
288
|
+
raise RuntimeError("No replay in progress. Nothing to abort.")
|
|
289
|
+
|
|
290
|
+
current = state["current_commit"]
|
|
291
|
+
logger.info("Aborting replay at commit %s...", current)
|
|
292
|
+
if not self.dry_run:
|
|
293
|
+
self.svn.revert_all()
|
|
294
|
+
clean_conflict_artifacts(self.svn.workspace_dir)
|
|
295
|
+
clear_replay_state(self.svn.workspace_dir)
|
|
296
|
+
logger.info("Replay aborted. SVN workspace reverted to last clean commit.")
|
|
297
|
+
|
|
298
|
+
def replay_skip(self) -> None:
|
|
299
|
+
"""Skip current interrupted commit and proceed with remaining queue."""
|
|
300
|
+
state = load_replay_state(self.svn.workspace_dir)
|
|
301
|
+
if not state:
|
|
302
|
+
raise RuntimeError("No replay in progress. Nothing to skip.")
|
|
303
|
+
|
|
304
|
+
current = state["current_commit"]
|
|
305
|
+
remaining = state.get("remaining_commits", [])
|
|
306
|
+
total = state.get("total_commits", len(remaining) + 1)
|
|
307
|
+
completed = state.get("completed_commits", 0)
|
|
308
|
+
|
|
309
|
+
logger.info("Skipping commit %s...", current)
|
|
310
|
+
if not self.dry_run:
|
|
311
|
+
self.svn.revert_all()
|
|
312
|
+
clean_conflict_artifacts(self.svn.workspace_dir)
|
|
313
|
+
|
|
314
|
+
if remaining:
|
|
315
|
+
self._execute_replay_queue(remaining, total_commits=total, start_index=completed + 2)
|
|
316
|
+
else:
|
|
317
|
+
clear_replay_state(self.svn.workspace_dir)
|
|
318
|
+
logger.info("Replay finished (last commit was skipped).")
|
|
319
|
+
|
|
320
|
+
def _execute_replay_queue(self, commits: List[str], total_commits: int, start_index: int) -> None:
|
|
321
|
+
"""Execute a list of commits sequentially, catching conflicts and persisting state."""
|
|
322
|
+
for idx, commit_hash in enumerate(commits, start=start_index):
|
|
323
|
+
commit_msg = self.git.get_commit_message(commit_hash)
|
|
324
|
+
first_line = commit_msg.splitlines()[0] if commit_msg else ""
|
|
325
|
+
logger.info("[%d/%d] Applying commit %s: %s", idx, total_commits, commit_hash[:8], first_line)
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
self._patch_and_stage_commit(commit_hash)
|
|
329
|
+
self.svn.commit(commit_msg)
|
|
330
|
+
except Exception:
|
|
331
|
+
remaining = commits[idx - start_index + 1 :]
|
|
332
|
+
state_data = {
|
|
333
|
+
"state": "CONFLICT_PAUSED",
|
|
334
|
+
"git_dir": str(self.git.repo_dir),
|
|
335
|
+
"svn_dir": str(self.svn.workspace_dir),
|
|
336
|
+
"current_commit": commit_hash,
|
|
337
|
+
"current_commit_msg": commit_msg,
|
|
338
|
+
"remaining_commits": remaining,
|
|
339
|
+
"total_commits": total_commits,
|
|
340
|
+
"completed_commits": idx - 1,
|
|
341
|
+
}
|
|
342
|
+
if not self.dry_run:
|
|
343
|
+
save_replay_state(self.svn.workspace_dir, state_data)
|
|
344
|
+
|
|
345
|
+
rej_files = find_conflict_artifacts(self.svn.workspace_dir)
|
|
346
|
+
rej_info = ""
|
|
347
|
+
if rej_files:
|
|
348
|
+
rej_rel = [str(r.relative_to(self.svn.workspace_dir)) for r in rej_files]
|
|
349
|
+
rej_info = "\nConflicts detected in:\n" + "\n".join(f" - {f}" for f in rej_rel)
|
|
350
|
+
|
|
351
|
+
print(
|
|
352
|
+
f'\n[PAUSED] Conflict while applying commit {commit_hash[:8]} ({idx}/{total_commits}): "{first_line}"'
|
|
353
|
+
f"{rej_info}\n\n"
|
|
354
|
+
f"To resolve:\n"
|
|
355
|
+
f" 1. Resolve conflicts in '{self.svn.workspace_dir}' and stage changes ('svn add' / 'svn rm').\n"
|
|
356
|
+
f" 2. Remove any leftover .rej / .orig files.\n"
|
|
357
|
+
f" 3. Run: git2svn replay --continue\n"
|
|
358
|
+
f" (or 'git2svn replay --abort' to discard, or 'git2svn replay --skip' to skip)\n",
|
|
359
|
+
file=sys.stderr,
|
|
360
|
+
)
|
|
361
|
+
raise
|
|
362
|
+
clear_replay_state(self.svn.workspace_dir)
|
|
363
|
+
logger.info("Replay completed successfully! All %d commits applied.", total_commits)
|
|
364
|
+
if self.auto_update:
|
|
365
|
+
self.svn.update()
|
|
366
|
+
|
|
367
|
+
def _patch_and_stage_commit(self, commit_hash: str) -> None:
|
|
368
|
+
"""Extract diff for a single commit, apply using git apply, and stage in SVN."""
|
|
369
|
+
parent = self.git.get_commit_parent(commit_hash)
|
|
370
|
+
if parent:
|
|
371
|
+
ref_spec = f"{parent}..{commit_hash}"
|
|
372
|
+
diff_text = self.git.get_diff(ref_spec)
|
|
373
|
+
changes = self.git.get_name_status(ref_spec)
|
|
374
|
+
else:
|
|
375
|
+
diff_text = self.git.get_diff_root(commit_hash)
|
|
376
|
+
changes = self.git.get_name_status_root(commit_hash)
|
|
377
|
+
|
|
378
|
+
target_eols: Dict[Path, Optional[bytes]] = {}
|
|
379
|
+
for change in changes:
|
|
380
|
+
if not change.is_deleted:
|
|
381
|
+
target_file = self.svn.workspace_dir / change.path
|
|
382
|
+
target_eols[change.path] = detect_file_eol(target_file)
|
|
383
|
+
|
|
384
|
+
self._ensure_parent_dirs_for_changes(changes)
|
|
385
|
+
self.patcher.apply_diff(diff_text)
|
|
386
|
+
|
|
387
|
+
if not self.dry_run:
|
|
388
|
+
for change in changes:
|
|
389
|
+
if not change.is_deleted:
|
|
390
|
+
target_file = self.svn.workspace_dir / change.path
|
|
391
|
+
orig_eol = target_eols.get(change.path)
|
|
392
|
+
normalize_file_eol(target_file, target_eol=orig_eol)
|
|
393
|
+
|
|
394
|
+
self.svn.apply_structural_changes(changes)
|
|
395
|
+
|
|
396
|
+
def _patch_and_stage_range(self, start_ref: str, end_ref: str) -> None:
|
|
397
|
+
"""Extract diff between two references, apply using git apply, and stage in SVN."""
|
|
398
|
+
ref_spec = f"{start_ref}..{end_ref}"
|
|
399
|
+
diff_text = self.git.get_diff(ref_spec)
|
|
400
|
+
changes = self.git.get_name_status(ref_spec)
|
|
401
|
+
|
|
402
|
+
target_eols: Dict[Path, Optional[bytes]] = {}
|
|
403
|
+
for change in changes:
|
|
404
|
+
if not change.is_deleted:
|
|
405
|
+
target_file = self.svn.workspace_dir / change.path
|
|
406
|
+
target_eols[change.path] = detect_file_eol(target_file)
|
|
407
|
+
|
|
408
|
+
self._ensure_parent_dirs_for_changes(changes)
|
|
409
|
+
self.patcher.apply_diff(diff_text)
|
|
410
|
+
|
|
411
|
+
if not self.dry_run:
|
|
412
|
+
for change in changes:
|
|
413
|
+
if not change.is_deleted:
|
|
414
|
+
target_file = self.svn.workspace_dir / change.path
|
|
415
|
+
orig_eol = target_eols.get(change.path)
|
|
416
|
+
normalize_file_eol(target_file, target_eol=orig_eol)
|
|
417
|
+
|
|
418
|
+
self.svn.apply_structural_changes(changes)
|
|
419
|
+
|
|
420
|
+
def _copy_and_stage_range(self, base_ref: str, target_ref: str) -> None:
|
|
421
|
+
"""Brute-force copy changed files using Git object DB, and stage in SVN."""
|
|
422
|
+
ref_spec = f"{base_ref}..{target_ref}"
|
|
423
|
+
changes = self.git.get_name_status(ref_spec)
|
|
424
|
+
|
|
425
|
+
target_eols: Dict[Path, Optional[bytes]] = {}
|
|
426
|
+
for change in changes:
|
|
427
|
+
if not change.is_deleted:
|
|
428
|
+
target_file = self.svn.workspace_dir / change.path
|
|
429
|
+
target_eols[change.path] = detect_file_eol(target_file)
|
|
430
|
+
|
|
431
|
+
# 1. Handle deleted files in SVN first
|
|
432
|
+
for change in changes:
|
|
433
|
+
if change.is_deleted:
|
|
434
|
+
self.svn.stage_rm(change.path)
|
|
435
|
+
target_file = self.svn.workspace_dir / change.path
|
|
436
|
+
if not self.dry_run and target_file.exists():
|
|
437
|
+
if target_file.is_dir():
|
|
438
|
+
shutil.rmtree(target_file)
|
|
439
|
+
else:
|
|
440
|
+
target_file.unlink()
|
|
441
|
+
elif change.is_renamed:
|
|
442
|
+
assert change.old_path is not None
|
|
443
|
+
self.svn.stage_rm(change.old_path)
|
|
444
|
+
target_old = self.svn.workspace_dir / change.old_path
|
|
445
|
+
if not self.dry_run and target_old.exists():
|
|
446
|
+
target_old.unlink()
|
|
447
|
+
|
|
448
|
+
# 2. Extract added, modified, renamed, and copied files directly from Git object database
|
|
449
|
+
for change in changes:
|
|
450
|
+
if change.is_added or change.is_modified or change.is_renamed or change.is_copied:
|
|
451
|
+
dst_path = self.svn.workspace_dir / change.path
|
|
452
|
+
|
|
453
|
+
if self.dry_run:
|
|
454
|
+
print(f"[DRY-RUN] Extract {target_ref}:{change.path.as_posix()} -> {dst_path}")
|
|
455
|
+
else:
|
|
456
|
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
457
|
+
if dst_path.exists() or dst_path.is_symlink():
|
|
458
|
+
dst_path.unlink()
|
|
459
|
+
|
|
460
|
+
mode = self.git.get_file_mode(target_ref, change.path)
|
|
461
|
+
content = self.git.get_file_content_bytes(target_ref, change.path)
|
|
462
|
+
|
|
463
|
+
if mode == "120000":
|
|
464
|
+
link_target = content.decode("utf-8", errors="replace").strip()
|
|
465
|
+
os.symlink(link_target, dst_path)
|
|
466
|
+
else:
|
|
467
|
+
dst_path.write_bytes(content)
|
|
468
|
+
orig_eol = target_eols.get(change.path)
|
|
469
|
+
if orig_eol:
|
|
470
|
+
normalize_file_eol(dst_path, target_eol=orig_eol)
|
|
471
|
+
|
|
472
|
+
# 3. Stage added, renamed, and copied files in SVN
|
|
473
|
+
for change in changes:
|
|
474
|
+
if change.is_added or change.is_renamed or change.is_copied:
|
|
475
|
+
self.svn.stage_add(change.path)
|
|
476
|
+
|
|
477
|
+
def _ensure_parent_dirs_for_changes(self, changes: List[FileChange]) -> None:
|
|
478
|
+
"""Create parent directories in SVN workspace for new/renamed files."""
|
|
479
|
+
for change in changes:
|
|
480
|
+
if change.is_added or change.is_renamed or change.is_copied:
|
|
481
|
+
target_file = self.svn.workspace_dir / change.path
|
|
482
|
+
if self.dry_run:
|
|
483
|
+
print(f"[DRY-RUN] Ensure directory exists: {target_file.parent}")
|
|
484
|
+
else:
|
|
485
|
+
target_file.parent.mkdir(parents=True, exist_ok=True)
|
git2svn/eol.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("git2svn")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def detect_file_eol(file_path: Path) -> Optional[bytes]:
|
|
11
|
+
"""
|
|
12
|
+
Detect the predominant newline style of a file (b'\\r\\n' vs b'\\n').
|
|
13
|
+
Returns None if the file does not exist, is empty, has no newlines, or is binary.
|
|
14
|
+
"""
|
|
15
|
+
if not file_path.is_file() or file_path.is_symlink():
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
data = file_path.read_bytes()
|
|
19
|
+
except OSError:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
if b"\0" in data[:4096]:
|
|
23
|
+
return None # Likely binary file
|
|
24
|
+
|
|
25
|
+
crlf_count = data.count(b"\r\n")
|
|
26
|
+
lf_count = data.count(b"\n") - crlf_count
|
|
27
|
+
|
|
28
|
+
if crlf_count > lf_count:
|
|
29
|
+
return b"\r\n"
|
|
30
|
+
if lf_count > 0:
|
|
31
|
+
return b"\n"
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_file_eol(file_path: Path, target_eol: Optional[bytes] = None) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Normalize all line endings in file_path to target_eol (b'\\r\\n' or b'\\n').
|
|
38
|
+
If target_eol is None, detects predominant newline in file_path (defaults to b'\\n').
|
|
39
|
+
Skips binary files and symlinks.
|
|
40
|
+
"""
|
|
41
|
+
if not file_path.is_file() or file_path.is_symlink():
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
data = file_path.read_bytes()
|
|
45
|
+
except OSError:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if b"\0" in data[:4096]:
|
|
49
|
+
return # Binary file
|
|
50
|
+
|
|
51
|
+
if target_eol is None:
|
|
52
|
+
crlf_count = data.count(b"\r\n")
|
|
53
|
+
lf_count = data.count(b"\n") - crlf_count
|
|
54
|
+
target_eol = b"\r\n" if crlf_count > lf_count else b"\n"
|
|
55
|
+
|
|
56
|
+
unified = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
57
|
+
if target_eol == b"\r\n":
|
|
58
|
+
normalized = unified.replace(b"\n", b"\r\n")
|
|
59
|
+
else:
|
|
60
|
+
normalized = unified
|
|
61
|
+
|
|
62
|
+
if normalized != data:
|
|
63
|
+
try:
|
|
64
|
+
file_path.write_bytes(normalized)
|
|
65
|
+
logger.debug("Normalized EOL in %s to %s", file_path, repr(target_eol.decode("ascii")))
|
|
66
|
+
except OSError as e:
|
|
67
|
+
logger.warning("Could not normalize line endings for %s: %s", file_path, e)
|