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 ADDED
@@ -0,0 +1,56 @@
1
+ """
2
+ git2svn: A consolidated CLI utility to synchronize changes from a local Git repository
3
+ to a local Subversion (SVN) working copy.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .cli import build_parser, main, parse_cli_args
9
+ from .core import Synchronizer
10
+ from .eol import detect_file_eol, normalize_file_eol
11
+ from .git import FileChange, GitRepo, parse_name_status, parse_name_status_z, 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
+ __version__ = "0.4.0"
23
+
24
+
25
+ def get_version() -> str:
26
+ """Return package version from installed distribution metadata or fallback to __version__."""
27
+ try:
28
+ from importlib.metadata import version
29
+
30
+ return version("git2svn")
31
+ except Exception:
32
+ return __version__
33
+
34
+
35
+ __all__ = [
36
+ "Synchronizer",
37
+ "GitRepo",
38
+ "SvnWorkspace",
39
+ "Patcher",
40
+ "FileChange",
41
+ "parse_ref_arguments",
42
+ "parse_name_status",
43
+ "parse_name_status_z",
44
+ "detect_file_eol",
45
+ "normalize_file_eol",
46
+ "save_replay_state",
47
+ "load_replay_state",
48
+ "clear_replay_state",
49
+ "find_conflict_artifacts",
50
+ "clean_conflict_artifacts",
51
+ "build_parser",
52
+ "parse_cli_args",
53
+ "main",
54
+ "get_version",
55
+ "__version__",
56
+ ]
git2svn/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from git2svn.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
git2svn/cli.py ADDED
@@ -0,0 +1,481 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+
11
+ from .core import Synchronizer
12
+ from .git import GitRepo
13
+ from .patcher import Patcher
14
+ from .state import load_replay_state
15
+ from .svn import SvnWorkspace
16
+
17
+ logger = logging.getLogger("git2svn")
18
+
19
+
20
+ def find_default_git_dir() -> Path:
21
+ """Find the Git repository root from current working directory."""
22
+ cwd = Path.cwd()
23
+ try:
24
+ res = subprocess.run(
25
+ ["git", "rev-parse", "--show-toplevel"],
26
+ cwd=cwd,
27
+ check=False,
28
+ capture_output=True,
29
+ text=True,
30
+ encoding="utf-8",
31
+ errors="replace",
32
+ )
33
+ if res.returncode == 0 and res.stdout.strip():
34
+ return Path(res.stdout.strip())
35
+ except Exception:
36
+ pass
37
+ return cwd
38
+
39
+
40
+ def build_parser() -> argparse.ArgumentParser:
41
+ common_parser = argparse.ArgumentParser(add_help=False)
42
+ common_parser.add_argument(
43
+ "--git-dir",
44
+ "-g",
45
+ type=Path,
46
+ default=argparse.SUPPRESS,
47
+ help="Path to Git repository (default: detected git root or current directory)",
48
+ )
49
+ common_parser.add_argument(
50
+ "--svn-dir",
51
+ "-s",
52
+ type=Path,
53
+ default=argparse.SUPPRESS,
54
+ help="Path to SVN working copy (default: $SVN_DIR environment variable)",
55
+ )
56
+ common_parser.add_argument(
57
+ "--dry-run",
58
+ "-n",
59
+ action="store_true",
60
+ default=argparse.SUPPRESS,
61
+ help="Print actions without modifying files or executing SVN commands",
62
+ )
63
+ common_parser.add_argument(
64
+ "--verbose",
65
+ "-v",
66
+ action="store_true",
67
+ default=argparse.SUPPRESS,
68
+ help="Enable verbose output",
69
+ )
70
+
71
+ parser = argparse.ArgumentParser(
72
+ prog="git2svn",
73
+ description=(
74
+ "Consolidated utility to synchronize Git revisions to an SVN workspace.\n\n"
75
+ "Core Actions:\n"
76
+ " stage NEVER commits. Applies Git changes to the SVN working copy (svn add/rm)\n"
77
+ " leaving files uncommitted for inspection, manual review, or squashing.\n"
78
+ " replay ALWAYS commits. Sequentially ports individual Git commits into SVN history,\n"
79
+ " preserving original author messages, commit order, and providing stateful\n"
80
+ " conflict pause/continue lifecycle management."
81
+ ),
82
+ formatter_class=argparse.RawDescriptionHelpFormatter,
83
+ parents=[common_parser],
84
+ )
85
+ from . import get_version
86
+
87
+ parser.add_argument(
88
+ "-V",
89
+ "--version",
90
+ action="version",
91
+ version=f"%(prog)s {get_version()}",
92
+ help="Show program's version number and exit",
93
+ )
94
+
95
+ subparsers = parser.add_subparsers(
96
+ dest="command",
97
+ title="commands",
98
+ description="Available commands",
99
+ required=True,
100
+ )
101
+
102
+ # stage
103
+ parser_stage = subparsers.add_parser(
104
+ "stage",
105
+ parents=[common_parser],
106
+ help="Stage changes in SVN workspace without committing (for review or squash)",
107
+ description=(
108
+ "Stage changes from a Git commit or range in the SVN workspace without committing.\n"
109
+ "Leaves the SVN working copy dirty (uncommitted) so you can inspect diffs,\n"
110
+ "review changes in TortoiseSVN/CLI, or commit manually."
111
+ ),
112
+ )
113
+ parser_stage.add_argument(
114
+ "ref1",
115
+ nargs="?",
116
+ default=None,
117
+ help="Commit hash, branch, or start ref (e.g. 'abc1234' or 'main..feature' or 'main')",
118
+ )
119
+ parser_stage.add_argument("ref2", nargs="?", default=None, help="End ref if range given as two arguments")
120
+ parser_stage.add_argument(
121
+ "--copy",
122
+ action="store_true",
123
+ default=argparse.SUPPRESS,
124
+ help="Brute-force copy modified/added files using Git object DB (bypasses patch; ideal for binaries/conflicts)",
125
+ )
126
+ parser_stage.add_argument(
127
+ "--snapshot",
128
+ action="store_true",
129
+ default=argparse.SUPPRESS,
130
+ help="Mirror the exact tree of ref1 onto SVN without knowing base ref (adds new, removes missing, updates modified)",
131
+ )
132
+
133
+ # replay
134
+ parser_replay = subparsers.add_parser(
135
+ "replay",
136
+ parents=[common_parser],
137
+ help="Replay commit(s) sequentially onto SVN, committing each with its Git message",
138
+ description=(
139
+ "Replay one or more Git commits sequentially into SVN history.\n"
140
+ "Each commit is applied and committed with its original Git message and metadata.\n"
141
+ "If a patch conflict occurs, replay pauses and persists state for interactive\n"
142
+ "resolution via --continue, --abort, or --skip."
143
+ ),
144
+ )
145
+ parser_replay.add_argument("ref1", nargs="?", default=None, help="Commit hash or start ref")
146
+ parser_replay.add_argument("ref2", nargs="?", default=None, help="End ref if range given as two arguments")
147
+ update_group = parser_replay.add_mutually_exclusive_group()
148
+ update_group.add_argument(
149
+ "-u",
150
+ "--update",
151
+ dest="update",
152
+ action="store_true",
153
+ default=argparse.SUPPRESS,
154
+ help="Run 'svn update' upon successful replay completion (enabled by default)",
155
+ )
156
+ update_group.add_argument(
157
+ "--no-update",
158
+ dest="update",
159
+ action="store_false",
160
+ default=argparse.SUPPRESS,
161
+ help="Disable automatic 'svn update' upon successful replay completion",
162
+ )
163
+ action_group = parser_replay.add_mutually_exclusive_group()
164
+ action_group.add_argument(
165
+ "--continue",
166
+ dest="replay_action",
167
+ action="store_const",
168
+ const="continue",
169
+ default=argparse.SUPPRESS,
170
+ help="Continue an in-progress replay after resolving conflicts",
171
+ )
172
+ action_group.add_argument(
173
+ "--abort",
174
+ dest="replay_action",
175
+ action="store_const",
176
+ const="abort",
177
+ default=argparse.SUPPRESS,
178
+ help="Abort in-progress replay and revert uncommitted changes",
179
+ )
180
+ action_group.add_argument(
181
+ "--skip",
182
+ dest="replay_action",
183
+ action="store_const",
184
+ const="skip",
185
+ default=argparse.SUPPRESS,
186
+ help="Skip the current failed commit and continue with the next",
187
+ )
188
+
189
+ # setup
190
+ parser_setup = subparsers.add_parser(
191
+ "setup",
192
+ parents=[common_parser],
193
+ help="Automate initial repository configuration, tracking branches, and git aliases",
194
+ description=(
195
+ "Configure repository settings for git2svn on a fresh clone.\n"
196
+ "Automatically detects local trunk and remote SVN mirror tracking branches,\n"
197
+ "sets git2svn.svnDir, git2svn.defaultRange, git2svn.autoUpdate, pull.ff only,\n"
198
+ "and configures git alias.svn-push and alias.svn-pull."
199
+ ),
200
+ )
201
+ parser_setup.add_argument(
202
+ "setup_svn_dir",
203
+ nargs="?",
204
+ type=Path,
205
+ default=None,
206
+ metavar="SVN_DIR",
207
+ help="Path to SVN working copy (optional if already configured or set via --svn-dir)",
208
+ )
209
+
210
+ return parser
211
+
212
+
213
+ def handle_setup(git_repo: GitRepo, svn_dir_path: Optional[Path | str]) -> int:
214
+ """Automate repository configuration, branch detection, and productivity aliases."""
215
+ if not git_repo.is_valid_repo():
216
+ print(f"Error: '{git_repo.root_dir}' is not a valid Git repository.", file=sys.stderr)
217
+ return 1
218
+
219
+ # 1. Resolve SVN working copy path
220
+ resolved_svn: Optional[Path] = None
221
+ if svn_dir_path:
222
+ resolved_svn = Path(svn_dir_path).resolve()
223
+ else:
224
+ # Check existing config or environment variable
225
+ cfg_svn = git_repo.get_config("git2svn.svnDir")
226
+ if cfg_svn:
227
+ resolved_svn = Path(cfg_svn).resolve()
228
+ elif "SVN_DIR" in os.environ:
229
+ resolved_svn = Path(os.environ["SVN_DIR"]).resolve()
230
+
231
+ if not resolved_svn:
232
+ # Interactive prompt if stdin is a tty, otherwise display error
233
+ if sys.stdin.isatty():
234
+ try:
235
+ entered = input("Path to SVN working copy: ").strip()
236
+ if entered:
237
+ resolved_svn = Path(entered).resolve()
238
+ except (EOFError, KeyboardInterrupt):
239
+ print("", file=sys.stderr)
240
+ return 1
241
+
242
+ if not resolved_svn:
243
+ print(
244
+ "Error: SVN working copy path must be provided: 'git2svn setup <path/to/svn>'.",
245
+ file=sys.stderr,
246
+ )
247
+ return 1
248
+
249
+ if not (resolved_svn / ".svn").exists():
250
+ print(
251
+ f"Error: '{resolved_svn}' does not appear to be an SVN working copy (no .svn found).",
252
+ file=sys.stderr,
253
+ )
254
+ return 1
255
+
256
+ # 2. Auto-detect local trunk branch
257
+ local_branches = git_repo.get_local_branches()
258
+ detected_trunk: Optional[str] = None
259
+ if "trunk" in local_branches:
260
+ detected_trunk = "trunk"
261
+ elif "main" in local_branches:
262
+ detected_trunk = "main"
263
+ elif "master" in local_branches:
264
+ detected_trunk = "master"
265
+ else:
266
+ # Use current branch if available
267
+ current = git_repo.get_current_branch()
268
+ if current and current in local_branches:
269
+ detected_trunk = current
270
+ elif local_branches:
271
+ detected_trunk = local_branches[0]
272
+
273
+ # 3. Auto-detect remote tracking mirror branch
274
+ remote_branches = git_repo.get_remote_branches()
275
+ detected_mirror: Optional[str] = None
276
+ preferred_remotes = ["svn-mirror/trunk", "origin/trunk", "svn/trunk", "mirror/trunk"]
277
+ for pref in preferred_remotes:
278
+ if pref in remote_branches:
279
+ detected_mirror = pref
280
+ break
281
+
282
+ if not detected_mirror and detected_trunk:
283
+ # Check for remote branch matching <remote>/<detected_trunk>
284
+ candidates = [b for b in remote_branches if b.endswith(f"/{detected_trunk}")]
285
+ if candidates:
286
+ # Prefer svn-mirror if present
287
+ svn_cand = [c for c in candidates if "svn" in c.lower() or "mirror" in c.lower()]
288
+ detected_mirror = svn_cand[0] if svn_cand else candidates[0]
289
+
290
+ # 4. Apply Git configuration
291
+ # SVN directory path (use forward slashes for cross-platform consistency in git config)
292
+ svn_dir_str = str(resolved_svn).replace("\\", "/")
293
+ git_repo.set_config("git2svn.svnDir", svn_dir_str)
294
+ git_repo.set_config("pull.ff", "only")
295
+
296
+ default_range: Optional[str] = None
297
+ if detected_mirror and detected_trunk:
298
+ default_range = f"{detected_mirror}..{detected_trunk}"
299
+ git_repo.set_config("git2svn.defaultRange", default_range)
300
+
301
+ # 5. Configure productivity aliases
302
+ mirror_remote = detected_mirror.split("/")[0] if detected_mirror else "svn-mirror"
303
+ trunk_name = detected_trunk or "trunk"
304
+
305
+ push_script = (
306
+ f"!f() {{ git2svn replay && run-svn2git-sync && git fetch {mirror_remote} && "
307
+ f"git checkout {trunk_name} && git reset --hard {detected_mirror or f'{mirror_remote}/{trunk_name}'}; }}; f"
308
+ )
309
+ pull_script = (
310
+ f"!f() {{ run-svn2git-sync && git fetch {mirror_remote} && "
311
+ f"git checkout {trunk_name} && git reset --hard {detected_mirror or f'{mirror_remote}/{trunk_name}'}; }}; f"
312
+ )
313
+
314
+ git_repo.set_config("alias.svn-push", push_script)
315
+ git_repo.set_config("alias.svn-pull", pull_script)
316
+
317
+ print("Successfully configured git2svn:")
318
+ print(f" git2svn.svnDir = {svn_dir_str}")
319
+ if default_range:
320
+ print(f" git2svn.defaultRange= {default_range}")
321
+ else:
322
+ print(" git2svn.defaultRange= (not set; could not detect mirror branch)")
323
+ print(" pull.ff = only")
324
+ print(f" alias.svn-push = {push_script}")
325
+ print(f" alias.svn-pull = {pull_script}")
326
+ return 0
327
+
328
+
329
+ def parse_cli_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
330
+ parser = build_parser()
331
+ effective_argv = sys.argv[1:] if argv is None else argv
332
+ if not effective_argv:
333
+ parser.print_help(sys.stderr)
334
+ sys.exit(1)
335
+
336
+ namespace = argparse.Namespace(
337
+ git_dir=None,
338
+ svn_dir=None,
339
+ dry_run=False,
340
+ verbose=False,
341
+ copy=False,
342
+ snapshot=False,
343
+ replay_action=None,
344
+ update=None,
345
+ )
346
+ return parser.parse_args(argv, namespace=namespace)
347
+
348
+
349
+ def main(argv: Optional[List[str]] = None) -> int:
350
+ args = parse_cli_args(argv)
351
+
352
+ log_level = logging.DEBUG if args.verbose else logging.INFO
353
+ logging.basicConfig(level=log_level, format="[%(levelname)s] %(message)s")
354
+
355
+ git_dir = args.git_dir
356
+ if not git_dir and args.command == "replay" and getattr(args, "replay_action", None):
357
+ cwd = Path.cwd()
358
+ state = load_replay_state(cwd)
359
+ if state and "git_dir" in state:
360
+ git_dir = Path(state["git_dir"])
361
+
362
+ git_dir = git_dir or find_default_git_dir()
363
+ git_repo = GitRepo(git_dir)
364
+ if not git_repo.is_valid_repo():
365
+ print(f"Error: '{git_dir}' is not a valid Git repository.", file=sys.stderr)
366
+ return 1
367
+
368
+ if args.command == "setup":
369
+ svn_arg = getattr(args, "setup_svn_dir", None) or getattr(args, "svn_dir", None)
370
+ return handle_setup(git_repo, svn_arg)
371
+
372
+ # 1. SVN workspace directory resolution: CLI arg -> $SVN_DIR -> git config -> replay cwd
373
+ svn_dir = args.svn_dir or (Path(os.environ["SVN_DIR"]) if "SVN_DIR" in os.environ else None)
374
+ if not svn_dir:
375
+ config_svn = git_repo.get_config("git2svn.svnDir")
376
+ if config_svn:
377
+ svn_dir = Path(config_svn)
378
+
379
+ # Auto-detect svn_dir from cwd for replay actions if cwd is an SVN checkout
380
+ if not svn_dir and args.command == "replay" and getattr(args, "replay_action", None):
381
+ cwd = Path.cwd()
382
+ if (cwd / ".svn").exists() or load_replay_state(cwd):
383
+ svn_dir = cwd
384
+
385
+ if not svn_dir:
386
+ print(
387
+ "Error: SVN workspace directory must be specified via --svn-dir, SVN_DIR env, or 'git config git2svn.svnDir <path>'.",
388
+ file=sys.stderr,
389
+ )
390
+ return 1
391
+
392
+ # Config fallbacks for dry_run and copy
393
+ dry_run = args.dry_run
394
+ if not dry_run:
395
+ config_dry_run = git_repo.get_config_bool("git2svn.dryRun")
396
+ if config_dry_run is not None:
397
+ dry_run = config_dry_run
398
+
399
+ use_copy = getattr(args, "copy", False)
400
+ if not use_copy:
401
+ config_copy = git_repo.get_config_bool("git2svn.copy")
402
+ if config_copy is not None:
403
+ use_copy = config_copy
404
+
405
+ # auto_update defaults to True unless CLI --no-update or git config git2svn.autoUpdate false
406
+ auto_update = getattr(args, "update", None)
407
+ if auto_update is None:
408
+ config_update = git_repo.get_config_bool("git2svn.autoUpdate")
409
+ if config_update is not None:
410
+ auto_update = config_update
411
+ else:
412
+ auto_update = True
413
+
414
+ svn_workspace = SvnWorkspace(svn_dir, dry_run=dry_run)
415
+ if not svn_workspace.is_valid_workspace() and not dry_run:
416
+ print(f"Error: '{svn_dir}' does not appear to be an SVN working copy (no .svn found).", file=sys.stderr)
417
+ return 1
418
+
419
+ patcher = Patcher(svn_dir, dry_run=dry_run)
420
+ sync_mgr = Synchronizer(git_repo, svn_workspace, patcher, dry_run=dry_run, auto_update=auto_update)
421
+
422
+ # Fallback to git2svn.defaultRange if ref1 is omitted
423
+ ref1 = args.ref1
424
+ ref2 = getattr(args, "ref2", None)
425
+ if not ref1 and args.command in ("stage", "replay"):
426
+ # Replay actions (--continue, --abort, --skip) do not need ref1
427
+ if args.command == "replay" and getattr(args, "replay_action", None):
428
+ pass
429
+ else:
430
+ default_range = git_repo.get_config("git2svn.defaultRange")
431
+ if default_range:
432
+ ref1 = default_range
433
+ ref2 = None
434
+ logger.info("Using configured default range from git2svn.defaultRange: '%s'", default_range)
435
+
436
+ try:
437
+ if args.command == "stage":
438
+ if not ref1:
439
+ print(
440
+ "Error: stage requires a commit or range (e.g. 'git2svn stage main..feature') "
441
+ "or 'git config git2svn.defaultRange <range>'.",
442
+ file=sys.stderr,
443
+ )
444
+ return 1
445
+ sync_mgr.stage(
446
+ ref1,
447
+ ref2,
448
+ use_copy=use_copy,
449
+ snapshot=getattr(args, "snapshot", False),
450
+ )
451
+ elif args.command == "replay":
452
+ action = getattr(args, "replay_action", None)
453
+ if action == "continue":
454
+ sync_mgr.replay_continue()
455
+ elif action == "abort":
456
+ sync_mgr.replay_abort()
457
+ elif action == "skip":
458
+ sync_mgr.replay_skip()
459
+ else:
460
+ if not ref1:
461
+ print(
462
+ "Error: replay requires a commit or range unless using --continue, --abort, or --skip. "
463
+ "You can also configure a default range with 'git config git2svn.defaultRange <range>'.",
464
+ file=sys.stderr,
465
+ )
466
+ return 1
467
+ sync_mgr.replay(ref1, ref2)
468
+ else:
469
+ return 1
470
+ except subprocess.CalledProcessError as e:
471
+ logger.error("Process failed with returncode %s", e.returncode)
472
+ return e.returncode
473
+ except Exception as e:
474
+ logger.error("Operation failed: %s", e)
475
+ if args.verbose:
476
+ import traceback
477
+
478
+ traceback.print_exc()
479
+ return 1
480
+
481
+ return 0