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/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)