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 +0 -0
- mr_manager/config.py +193 -0
- mr_manager/discovery.py +61 -0
- mr_manager/main.py +12 -0
- mr_manager/ui.py +443 -0
- mr_manager/ui.tcss +104 -0
- mr_manager-0.0.1.dist-info/METADATA +769 -0
- mr_manager-0.0.1.dist-info/RECORD +11 -0
- mr_manager-0.0.1.dist-info/WHEEL +4 -0
- mr_manager-0.0.1.dist-info/entry_points.txt +2 -0
- mr_manager-0.0.1.dist-info/licenses/LICENSE +674 -0
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")
|
mr_manager/discovery.py
ADDED
|
@@ -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())
|