mr-manager 0.0.1__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.
mr_manager/__init__.py ADDED
File without changes
mr_manager/config.py ADDED
@@ -0,0 +1,193 @@
1
+ """myrepos configuration parsing and update helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ _SECTION_HEADER_PATTERN = re.compile(r"^\s*\[([^]]+)]\s*$")
10
+
11
+
12
+ def _normalize_repo_reference(config_path: Path, repo_reference: str) -> Path:
13
+ """Normalize a repository reference from config into an absolute path.
14
+
15
+ Args:
16
+ config_path: Path to the myrepos config file.
17
+ repo_reference: Section header value from config.
18
+
19
+ Returns:
20
+ Absolute normalized repository path.
21
+ """
22
+ reference_path = Path(repo_reference).expanduser()
23
+ if reference_path.is_absolute():
24
+ return reference_path.resolve(strict=False)
25
+ return (config_path.parent / reference_path).resolve(strict=False)
26
+
27
+
28
+ def parse_configured_repo_sections(config_path: Path) -> dict[Path, list[str]]:
29
+ """Parse repository section headers from a myrepos config file.
30
+
31
+ Args:
32
+ config_path: Path to the myrepos config file.
33
+
34
+ Returns:
35
+ Mapping of normalized repository path to original section names.
36
+ """
37
+ if not config_path.exists():
38
+ return {}
39
+
40
+ configured: dict[Path, list[str]] = {}
41
+ for line in config_path.read_text(encoding="utf-8").splitlines():
42
+ match = _SECTION_HEADER_PATTERN.match(line)
43
+ if not match:
44
+ continue
45
+ section_name = match.group(1).strip()
46
+ if section_name.upper() == "DEFAULT":
47
+ continue
48
+ normalized_path = _normalize_repo_reference(config_path, section_name)
49
+ configured.setdefault(normalized_path, []).append(section_name)
50
+ return configured
51
+
52
+
53
+ def _remove_sections_by_name(lines: list[str], section_names_to_remove: set[str]) -> list[str]:
54
+ """Remove named section blocks from config text lines.
55
+
56
+ Args:
57
+ lines: Config file lines including newline characters when available.
58
+ section_names_to_remove: Section names that should be removed fully.
59
+
60
+ Returns:
61
+ Updated list of config lines with matching sections removed.
62
+ """
63
+ if not section_names_to_remove:
64
+ return lines
65
+
66
+ section_ranges: list[tuple[str, int, int]] = []
67
+ section_starts: list[tuple[str, int]] = []
68
+ for index, line in enumerate(lines):
69
+ match = _SECTION_HEADER_PATTERN.match(line.strip())
70
+ if match:
71
+ section_starts.append((match.group(1).strip(), index))
72
+
73
+ for index, (section_name, start) in enumerate(section_starts):
74
+ end = section_starts[index + 1][1] if index + 1 < len(section_starts) else len(lines)
75
+ section_ranges.append((section_name, start, end))
76
+
77
+ updated_lines = lines.copy()
78
+ for section_name, start, end in reversed(section_ranges):
79
+ if section_name in section_names_to_remove:
80
+ del updated_lines[start:end]
81
+
82
+ return updated_lines
83
+
84
+
85
+ def _shell_single_quote(value: str) -> str:
86
+ """Return a shell-safe single-quoted string.
87
+
88
+ Args:
89
+ value: Raw string value to quote.
90
+
91
+ Returns:
92
+ Single-quoted shell-safe representation.
93
+ """
94
+ escaped_value = value.replace("'", "'\"'\"'")
95
+ return f"'{escaped_value}'"
96
+
97
+
98
+ def _format_section_name(config_path: Path, repo_path: Path) -> str:
99
+ """Format a repository path into mr section style.
100
+
101
+ Args:
102
+ config_path: Path to the myrepos config file.
103
+ repo_path: Absolute repository path.
104
+
105
+ Returns:
106
+ Path relative to config directory when possible, otherwise absolute path.
107
+ """
108
+ config_dir = config_path.parent.resolve(strict=False)
109
+ resolved_repo_path = repo_path.resolve(strict=False)
110
+ try:
111
+ return resolved_repo_path.relative_to(config_dir).as_posix()
112
+ except ValueError:
113
+ return resolved_repo_path.as_posix()
114
+
115
+
116
+ def _resolve_clone_source(repo_path: Path) -> str:
117
+ """Resolve clone source for a repository checkout command.
118
+
119
+ Args:
120
+ repo_path: Repository path on disk.
121
+
122
+ Returns:
123
+ Remote origin URL when available, otherwise local repository path.
124
+ """
125
+ try:
126
+ result = subprocess.run(
127
+ ["git", "-C", str(repo_path), "config", "--get", "remote.origin.url"],
128
+ capture_output=True,
129
+ text=True,
130
+ check=False,
131
+ )
132
+ except FileNotFoundError as error:
133
+ msg = "git executable not found while resolving repository clone source."
134
+ raise RuntimeError(msg) from error
135
+
136
+ origin_url = result.stdout.strip()
137
+ if origin_url:
138
+ return origin_url
139
+ return repo_path.resolve(strict=False).as_posix()
140
+
141
+
142
+ def _build_repo_block(config_path: Path, repo_path: Path) -> str:
143
+ """Build a myrepos section block for one repository.
144
+
145
+ Args:
146
+ config_path: Path to the myrepos config file.
147
+ repo_path: Repository path to represent in config.
148
+
149
+ Returns:
150
+ Section block with `[path]` and `checkout = git clone ...`.
151
+ """
152
+ section_name = _format_section_name(config_path, repo_path)
153
+ clone_source = _resolve_clone_source(repo_path)
154
+ checkout_target = repo_path.name
155
+ checkout_command = (
156
+ f"checkout = git clone {_shell_single_quote(clone_source)} "
157
+ f"{_shell_single_quote(checkout_target)}"
158
+ )
159
+ return f"[{section_name}]\n{checkout_command}"
160
+
161
+
162
+ def write_config_updates(
163
+ config_path: Path,
164
+ repos_to_add: list[Path],
165
+ section_names_to_remove: set[str],
166
+ ) -> None:
167
+ """Apply add/remove updates to the myrepos config file.
168
+
169
+ Args:
170
+ config_path: Path to the myrepos config file.
171
+ repos_to_add: Repository paths to append in `[path]` + `checkout` block style.
172
+ section_names_to_remove: Original section names to remove fully with their body lines.
173
+ """
174
+ existing_lines = (
175
+ config_path.read_text(encoding="utf-8").splitlines(keepends=True)
176
+ if config_path.exists()
177
+ else []
178
+ )
179
+ preserved_lines = _remove_sections_by_name(existing_lines, section_names_to_remove)
180
+ preserved_text = "".join(preserved_lines).rstrip()
181
+ additions_text = "\n\n".join(_build_repo_block(config_path, repo) for repo in repos_to_add)
182
+
183
+ if preserved_text and additions_text:
184
+ updated_text = f"{preserved_text}\n\n{additions_text}\n"
185
+ elif preserved_text:
186
+ updated_text = f"{preserved_text}\n"
187
+ elif additions_text:
188
+ updated_text = f"{additions_text}\n"
189
+ else:
190
+ updated_text = ""
191
+
192
+ config_path.parent.mkdir(parents=True, exist_ok=True)
193
+ config_path.write_text(updated_text, encoding="utf-8")
@@ -0,0 +1,61 @@
1
+ """Repository discovery helpers for filesystem scanning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ _IGNORED_DISCOVERY_DIRS = {
9
+ "Library",
10
+ "Applications",
11
+ "Movies",
12
+ "Music",
13
+ "Pictures",
14
+ "Downloads",
15
+ "Public",
16
+ ".Trash",
17
+ ".cache",
18
+ ".local",
19
+ ".npm",
20
+ ".cargo",
21
+ ".rustup",
22
+ "node_modules",
23
+ ".venv",
24
+ "venv",
25
+ }
26
+
27
+
28
+ def _should_descend_directory(directory_name: str) -> bool:
29
+ """Return whether a directory should be traversed during repository discovery.
30
+
31
+ Args:
32
+ directory_name: Candidate child directory name from os.walk.
33
+
34
+ Returns:
35
+ True when the directory should be traversed, otherwise False.
36
+ """
37
+ if directory_name in {".", ".."}:
38
+ return False
39
+ return directory_name not in _IGNORED_DISCOVERY_DIRS
40
+
41
+
42
+ def discover_git_repositories(root: Path) -> list[Path]:
43
+ """Discover Git repositories recursively below a root directory.
44
+
45
+ Args:
46
+ root: Filesystem directory used as scan root.
47
+
48
+ Returns:
49
+ Sorted absolute repository paths where a `.git` directory exists.
50
+ """
51
+ discovered: list[Path] = []
52
+ for current_root, dirs, _ in os.walk(root, topdown=True):
53
+ if ".git" in dirs:
54
+ discovered.append(Path(current_root).resolve())
55
+ # Repo detected: skip descending into its working tree for speed.
56
+ dirs.clear()
57
+ continue
58
+
59
+ dirs[:] = [directory for directory in dirs if _should_descend_directory(directory)]
60
+
61
+ return sorted(discovered, key=lambda repo: str(repo).lower())
mr_manager/main.py ADDED
@@ -0,0 +1,12 @@
1
+ """Application entrypoint for mr-manager."""
2
+
3
+ from mr_manager.ui import MrManagerApp
4
+
5
+
6
+ def main() -> None:
7
+ """Start the mr-manager Textual application."""
8
+ MrManagerApp().run()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()