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