mgit 2.0.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.
- mgit/__init__.py +40 -0
- mgit/cli.py +371 -0
- mgit/git.py +712 -0
- mgit-2.0.0.dist-info/METADATA +211 -0
- mgit-2.0.0.dist-info/RECORD +8 -0
- mgit-2.0.0.dist-info/WHEEL +5 -0
- mgit-2.0.0.dist-info/entry_points.txt +2 -0
- mgit-2.0.0.dist-info/top_level.txt +1 -0
mgit/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import runez
|
|
6
|
+
|
|
7
|
+
from mgit.git import GitDir, Reporter
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProjectDir:
|
|
14
|
+
"""One requested workspace, represented as one or more git dirs."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, path: Path):
|
|
17
|
+
"""
|
|
18
|
+
:param Path path: Path to folder
|
|
19
|
+
"""
|
|
20
|
+
self.path = path
|
|
21
|
+
self.git_dirs: list[GitDir] = []
|
|
22
|
+
self.name_size: int | None = None
|
|
23
|
+
self.scan()
|
|
24
|
+
|
|
25
|
+
def scan(self):
|
|
26
|
+
self.git_dirs = [
|
|
27
|
+
GitDir(source_path)
|
|
28
|
+
for source_path in self.path.iterdir()
|
|
29
|
+
if not source_path.name.startswith(".") and source_path.is_dir() and (source_path / ".git").is_dir()
|
|
30
|
+
]
|
|
31
|
+
self.git_dirs = sorted(self.git_dirs, key=lambda x: x.basename)
|
|
32
|
+
Reporter.abort_if(not self.git_dirs, f"{runez.short(self.path)}: no git folders")
|
|
33
|
+
self.name_size = min(36, max(len(git.basename) for git in self.git_dirs)) if len(self.git_dirs) > 1 else None
|
|
34
|
+
|
|
35
|
+
def prefixed_line(self, git: GitDir, line: str) -> str:
|
|
36
|
+
name = git.basename
|
|
37
|
+
if self.name_size:
|
|
38
|
+
name = f"{name:>{self.name_size}}"
|
|
39
|
+
|
|
40
|
+
return f"{name}: {line}"
|
mgit/cli.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import runez
|
|
12
|
+
|
|
13
|
+
from mgit import ProjectDir, Reporter
|
|
14
|
+
from mgit.git import GitDir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def command_name_from_class(cls: type) -> str:
|
|
18
|
+
name = cls.__name__.removesuffix("Command")
|
|
19
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CliCommand(ABC):
|
|
23
|
+
"""API for all CLI commands."""
|
|
24
|
+
|
|
25
|
+
short_name: str | None = None
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def command_name(cls) -> str:
|
|
29
|
+
return command_name_from_class(cls)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def summary(cls) -> str:
|
|
33
|
+
"""Every command class must have a summary (intentional crash otherwise)"""
|
|
34
|
+
assert cls.__doc__ # noqa: S101, internal error
|
|
35
|
+
return cls.__doc__.splitlines()[0]
|
|
36
|
+
|
|
37
|
+
@classmethod # noqa: B027 - optional hook
|
|
38
|
+
def add_arguments(cls, _parser: argparse.ArgumentParser):
|
|
39
|
+
"""Add command-specific arguments."""
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def from_namespace(cls, _namespace: argparse.Namespace) -> CliCommand:
|
|
44
|
+
"""Create a command from parsed CLI arguments."""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def run(self):
|
|
48
|
+
"""Run the command."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FolderTargetCommand(CliCommand):
|
|
52
|
+
folder: Path | None = None
|
|
53
|
+
|
|
54
|
+
def __init__(self, folder: Path | None):
|
|
55
|
+
self.folder = folder
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def add_arguments(cls, parser: argparse.ArgumentParser):
|
|
59
|
+
parser.add_argument("folder", nargs="?", type=Path)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_namespace(cls, namespace: argparse.Namespace) -> FolderTargetCommand:
|
|
63
|
+
return cls(folder=namespace.folder)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def current_checkout() -> Path | None:
|
|
67
|
+
current = Path.cwd()
|
|
68
|
+
for candidate in (current, *current.parents):
|
|
69
|
+
if (candidate / ".git").is_dir():
|
|
70
|
+
return candidate
|
|
71
|
+
|
|
72
|
+
def target(self) -> GitDir | ProjectDir:
|
|
73
|
+
if self.folder is None:
|
|
74
|
+
current = self.current_checkout()
|
|
75
|
+
if current:
|
|
76
|
+
return GitDir(current)
|
|
77
|
+
|
|
78
|
+
folder = (self.folder or Path(".")).expanduser().absolute()
|
|
79
|
+
Reporter.abort_if(not folder.is_dir(), f"No folder '{runez.short(folder)}'")
|
|
80
|
+
if (folder / ".git").is_dir():
|
|
81
|
+
return GitDir(folder)
|
|
82
|
+
|
|
83
|
+
return ProjectDir(folder)
|
|
84
|
+
|
|
85
|
+
def run(self):
|
|
86
|
+
"""Run the command."""
|
|
87
|
+
target = self.target()
|
|
88
|
+
if isinstance(target, GitDir):
|
|
89
|
+
return self.run_single(target)
|
|
90
|
+
|
|
91
|
+
return self.run_multi(target)
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def run_single(self, git: GitDir):
|
|
95
|
+
"""Run this command for one git dir."""
|
|
96
|
+
|
|
97
|
+
def run_multi(self, _project_dir: ProjectDir) -> None:
|
|
98
|
+
"""Run this command for all git dirs in `project_dir`."""
|
|
99
|
+
Reporter.abort(f"{self.command_name()} only supports one git checkout")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
COMMANDS: list[type[CliCommand]] = []
|
|
103
|
+
COMMAND_BY_TOKEN: dict[str, type[CliCommand]] = {}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def register_cli_command(name: str | None, command: type[CliCommand]):
|
|
107
|
+
assert name # noqa: S101, this would be an internal error, detected at test time
|
|
108
|
+
assert name not in COMMAND_BY_TOKEN # noqa: S101
|
|
109
|
+
COMMAND_BY_TOKEN[name] = command
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cli_command(command: type[CliCommand]) -> type[CliCommand]:
|
|
113
|
+
COMMANDS.append(command)
|
|
114
|
+
register_cli_command(command.command_name(), command)
|
|
115
|
+
register_cli_command(command.short_name, command)
|
|
116
|
+
return command
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@cli_command
|
|
120
|
+
class StatusCommand(FolderTargetCommand):
|
|
121
|
+
"""Show repo or workspace status."""
|
|
122
|
+
|
|
123
|
+
short_name = "s"
|
|
124
|
+
|
|
125
|
+
def run_single(self, git: GitDir):
|
|
126
|
+
print(git.status_line())
|
|
127
|
+
details = git.status_details()
|
|
128
|
+
if details:
|
|
129
|
+
print(details)
|
|
130
|
+
|
|
131
|
+
def run_multi(self, project_dir: ProjectDir):
|
|
132
|
+
for git in project_dir.git_dirs:
|
|
133
|
+
print(project_dir.prefixed_line(git, git.status_line()))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@cli_command
|
|
137
|
+
class FetchCommand(FolderTargetCommand):
|
|
138
|
+
"""Fetch remotes, then show status."""
|
|
139
|
+
|
|
140
|
+
short_name = "f"
|
|
141
|
+
|
|
142
|
+
def run_single(self, git: GitDir):
|
|
143
|
+
report = git.fetch_now().require_success("fetch")
|
|
144
|
+
print(git.status_line(report))
|
|
145
|
+
details = git.status_details()
|
|
146
|
+
if details:
|
|
147
|
+
print(details)
|
|
148
|
+
|
|
149
|
+
def run_multi(self, project_dir: ProjectDir):
|
|
150
|
+
for git in project_dir.git_dirs:
|
|
151
|
+
report = git.fetch_now()
|
|
152
|
+
print(project_dir.prefixed_line(git, git.status_line(report)))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@cli_command
|
|
156
|
+
class PullCommand(FolderTargetCommand):
|
|
157
|
+
"""Pull with rebase when the worktree is safe."""
|
|
158
|
+
|
|
159
|
+
short_name = "p"
|
|
160
|
+
|
|
161
|
+
def run_single(self, git: GitDir):
|
|
162
|
+
report = git.pull().require_success("pull")
|
|
163
|
+
print(git.status_line(report))
|
|
164
|
+
details = git.status_details()
|
|
165
|
+
if details:
|
|
166
|
+
print(details)
|
|
167
|
+
|
|
168
|
+
def run_multi(self, project_dir: ProjectDir):
|
|
169
|
+
for git in project_dir.git_dirs:
|
|
170
|
+
report = git.pull()
|
|
171
|
+
print(project_dir.prefixed_line(git, git.status_line(report)))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@cli_command
|
|
175
|
+
class MainCommand(FolderTargetCommand):
|
|
176
|
+
"""Checkout the default branch."""
|
|
177
|
+
|
|
178
|
+
short_name = "m"
|
|
179
|
+
|
|
180
|
+
def run_single(self, git: GitDir):
|
|
181
|
+
report = git.checkout_default_branch()
|
|
182
|
+
print(git.status_line(report))
|
|
183
|
+
details = git.status_details()
|
|
184
|
+
if details:
|
|
185
|
+
print(details)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@cli_command
|
|
189
|
+
class BranchesCommand(FolderTargetCommand):
|
|
190
|
+
"""Show local branches."""
|
|
191
|
+
|
|
192
|
+
short_name = "b"
|
|
193
|
+
|
|
194
|
+
def run_single(self, git: GitDir):
|
|
195
|
+
details = git.branch_details()
|
|
196
|
+
if details:
|
|
197
|
+
print(details)
|
|
198
|
+
|
|
199
|
+
def run_multi(self, project_dir: ProjectDir):
|
|
200
|
+
show_names = len(project_dir.git_dirs) > 1
|
|
201
|
+
for git in project_dir.git_dirs:
|
|
202
|
+
if show_names:
|
|
203
|
+
print(f"{git.basename}:")
|
|
204
|
+
|
|
205
|
+
indent = " " if show_names else ""
|
|
206
|
+
details = git.branch_details(indent=indent)
|
|
207
|
+
if details:
|
|
208
|
+
print(details)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@cli_command
|
|
212
|
+
class GroomCommand(FolderTargetCommand):
|
|
213
|
+
"""Fetch, return to default branch, pull, and clean the groomed branch."""
|
|
214
|
+
|
|
215
|
+
short_name = "g"
|
|
216
|
+
|
|
217
|
+
def _checkout_default_branch(self, git: GitDir, branch: str):
|
|
218
|
+
git.checked_git_command("checkout", branch)
|
|
219
|
+
git.clear_cached_state()
|
|
220
|
+
print(f"Checked out {git.represented_branch(branch)} branch")
|
|
221
|
+
|
|
222
|
+
def _delete_local_branch(self, git: GitDir, cleanup):
|
|
223
|
+
args = ["branch", "--delete", cleanup.name]
|
|
224
|
+
if cleanup.force_delete:
|
|
225
|
+
args.insert(2, "--force")
|
|
226
|
+
|
|
227
|
+
git.checked_git_command(*args)
|
|
228
|
+
print(f"Deleted branch {git.represented_branch(cleanup.name)}")
|
|
229
|
+
git.clear_cached_state()
|
|
230
|
+
|
|
231
|
+
def _delete_remote_branch(self, git: GitDir, cleanup):
|
|
232
|
+
branch_ref = f"refs/heads/{cleanup.branch}"
|
|
233
|
+
git.checked_git_command(
|
|
234
|
+
"push",
|
|
235
|
+
f"--force-with-lease={branch_ref}:{cleanup.expected_oid}",
|
|
236
|
+
"--delete",
|
|
237
|
+
cleanup.remote,
|
|
238
|
+
branch_ref,
|
|
239
|
+
)
|
|
240
|
+
print(f"Deleted remote branch {cleanup.remote}/{git.represented_branch(cleanup.branch)}")
|
|
241
|
+
git.clear_cached_state()
|
|
242
|
+
|
|
243
|
+
def run_single(self, git: GitDir):
|
|
244
|
+
report = git.fetch_now().require_success("groom")
|
|
245
|
+
refs = git.refs
|
|
246
|
+
current_branch = refs.current
|
|
247
|
+
default_branch = git.default_branch
|
|
248
|
+
if current_branch == default_branch:
|
|
249
|
+
report.add(note="already on default branch")
|
|
250
|
+
print(git.status_line(report))
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
git.status.require_clean("groom")
|
|
254
|
+
remote_cleanup = None
|
|
255
|
+
local_cleanup = git.cleanable_local_branch(current_branch, include_current=True)
|
|
256
|
+
if not local_cleanup:
|
|
257
|
+
Reporter.abort(f"Branch {git.represented_branch(current_branch)} can't be cleaned")
|
|
258
|
+
|
|
259
|
+
upstream = refs.upstreams.get(current_branch)
|
|
260
|
+
if upstream and refs.has_remote_branch(upstream.remote, upstream.branch):
|
|
261
|
+
remote_cleanup = git.cleanable_current_remote_branch()
|
|
262
|
+
if not remote_cleanup:
|
|
263
|
+
Reporter.abort(f"Remote branch {upstream.remote}/{git.represented_branch(upstream.branch)} can't be cleaned automatically")
|
|
264
|
+
|
|
265
|
+
self._checkout_default_branch(git, default_branch)
|
|
266
|
+
report = git.pull().require_success("groom")
|
|
267
|
+
git.clear_cached_state()
|
|
268
|
+
if remote_cleanup:
|
|
269
|
+
self._delete_remote_branch(git, remote_cleanup)
|
|
270
|
+
|
|
271
|
+
self._delete_local_branch(git, local_cleanup)
|
|
272
|
+
print(git.status_line(report))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class CommandHelpFormatter(argparse.HelpFormatter):
|
|
276
|
+
"""Show subcommands as a compact command list."""
|
|
277
|
+
|
|
278
|
+
def _format_action(self, action: argparse.Action) -> str:
|
|
279
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
280
|
+
return "".join(self._format_action(choice_action) for choice_action in action._get_subactions())
|
|
281
|
+
|
|
282
|
+
return super()._format_action(action)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def add_command_parser(subparsers: argparse._SubParsersAction, command: type[CliCommand]):
|
|
286
|
+
aliases = [command.short_name] if command.short_name else []
|
|
287
|
+
parser = subparsers.add_parser(
|
|
288
|
+
command.command_name(),
|
|
289
|
+
aliases=aliases,
|
|
290
|
+
description=command.summary(),
|
|
291
|
+
formatter_class=CommandHelpFormatter,
|
|
292
|
+
help=command.summary(),
|
|
293
|
+
prog=f"mgit {command.command_name()}",
|
|
294
|
+
)
|
|
295
|
+
parser._action_groups[0].title = "Arguments"
|
|
296
|
+
command.add_arguments(parser)
|
|
297
|
+
parser.set_defaults(command_type=command)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def split_global_args(args: list[str]) -> tuple[list[str], list[str]]:
|
|
301
|
+
global_args = []
|
|
302
|
+
i = 0
|
|
303
|
+
while i < len(args):
|
|
304
|
+
arg = args[i]
|
|
305
|
+
if arg in {"--help", "-v", "--verbose", "--version"}:
|
|
306
|
+
global_args.append(arg)
|
|
307
|
+
i += 1
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
if arg == "--color":
|
|
311
|
+
global_args.append(arg)
|
|
312
|
+
i += 1
|
|
313
|
+
if i < len(args):
|
|
314
|
+
global_args.append(args[i])
|
|
315
|
+
i += 1
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
if arg.startswith("--color="):
|
|
319
|
+
global_args.append(arg)
|
|
320
|
+
i += 1
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
if arg.startswith("-"):
|
|
324
|
+
global_args.append(arg)
|
|
325
|
+
i += 1
|
|
326
|
+
|
|
327
|
+
break
|
|
328
|
+
|
|
329
|
+
return global_args, args[i:]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def normalized_cli_args(args: list[str]) -> list[str]:
|
|
333
|
+
global_args, command_args = split_global_args(args)
|
|
334
|
+
if global_args and global_args[-1] == "--color":
|
|
335
|
+
return global_args
|
|
336
|
+
|
|
337
|
+
if command_args:
|
|
338
|
+
command = COMMAND_BY_TOKEN.get(command_args[0])
|
|
339
|
+
if command:
|
|
340
|
+
command_args[0] = command.command_name()
|
|
341
|
+
|
|
342
|
+
else:
|
|
343
|
+
command_args.insert(0, StatusCommand.command_name())
|
|
344
|
+
|
|
345
|
+
else:
|
|
346
|
+
command_args.append(StatusCommand.command_name())
|
|
347
|
+
|
|
348
|
+
return global_args + command_args
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main():
|
|
352
|
+
parser = argparse.ArgumentParser(
|
|
353
|
+
prog="mgit",
|
|
354
|
+
usage="mgit [GLOBAL_OPTIONS] [COMMAND] [ARGS...]",
|
|
355
|
+
description="Inspect and update git checkouts.",
|
|
356
|
+
formatter_class=CommandHelpFormatter,
|
|
357
|
+
)
|
|
358
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging.")
|
|
359
|
+
parser.add_argument("--color", choices=("auto", "always", "never"), default="auto", help="Control ANSI color output.")
|
|
360
|
+
parser.add_argument("--version", action="version", version=f"mgit {version('mgit')}")
|
|
361
|
+
subparsers = parser.add_subparsers(dest="command", title="Commands", metavar="COMMAND")
|
|
362
|
+
subparsers.required = True
|
|
363
|
+
for command in COMMANDS:
|
|
364
|
+
add_command_parser(subparsers, command)
|
|
365
|
+
|
|
366
|
+
namespace = parser.parse_args(normalized_cli_args(sys.argv[1:]))
|
|
367
|
+
command = namespace.command_type.from_namespace(namespace)
|
|
368
|
+
with runez.ActivateColors(None if namespace.color == "auto" else namespace.color == "always"):
|
|
369
|
+
runez.date.DEFAULT_DURATION_SPAN = -2
|
|
370
|
+
runez.log.setup(debug=namespace.verbose, level=logging.INFO, console_format="%(levelname)s %(message)s", locations=None)
|
|
371
|
+
command.run()
|
mgit/git.py
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from functools import cached_property
|
|
9
|
+
from typing import NoReturn, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import runez
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Reporter:
|
|
18
|
+
"""Central user-facing reporting helpers."""
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("mgit")
|
|
21
|
+
|
|
22
|
+
branch_default = runez.green
|
|
23
|
+
branch_orphaned = runez.orange
|
|
24
|
+
problem = runez.red
|
|
25
|
+
note = runez.purple
|
|
26
|
+
progress = runez.plain
|
|
27
|
+
index_change = runez.teal
|
|
28
|
+
worktree_change = runez.red
|
|
29
|
+
untracked_change = runez.orange
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def joined(*args, separator=" "):
|
|
33
|
+
"""Join non-false-ish display fragments."""
|
|
34
|
+
return runez.joined(*args, delimiter=separator, keep_empty=None)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def joined_lines(*args, header="", indent="", separator="\n"):
|
|
38
|
+
"""Join non-false-ish display lines with optional header and indentation."""
|
|
39
|
+
lines = [f"{indent}{line}" for line in runez.flattened(args, keep_empty=None)]
|
|
40
|
+
return Reporter.joined(header, lines, separator=separator)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def abort(message, exit_code: int = 1) -> NoReturn:
|
|
44
|
+
print(Reporter.problem(message), file=sys.stderr)
|
|
45
|
+
sys.exit(exit_code)
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def abort_if(condition: object, message, exit_code: int = 1):
|
|
49
|
+
if condition:
|
|
50
|
+
Reporter.abort(message, exit_code=exit_code)
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def debug(message: str, *args: object):
|
|
54
|
+
Reporter.log.debug(message, *args)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
FRESH_FETCH_THRESHOLD = 30
|
|
58
|
+
FRESHNESS_THRESHOLD = 12 * runez.date.SECONDS_IN_ONE_HOUR
|
|
59
|
+
LOCAL_REF_PREFIX = "refs/heads/"
|
|
60
|
+
REMOTE_REF_PREFIX = "refs/remotes/"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def git_error_message(proc: subprocess.CompletedProcess[str]) -> str | None:
|
|
64
|
+
"""Short user-facing description of a failed git command."""
|
|
65
|
+
if proc.returncode:
|
|
66
|
+
detail = (proc.stderr or proc.stdout).strip() or f"git exited with code {proc.returncode}"
|
|
67
|
+
detail = detail.strip().lower()
|
|
68
|
+
if "following untracked" in detail:
|
|
69
|
+
return "untracked files would be overwritten"
|
|
70
|
+
|
|
71
|
+
if "repository not found" in detail:
|
|
72
|
+
return "repository not found"
|
|
73
|
+
|
|
74
|
+
lines = []
|
|
75
|
+
prefixed = []
|
|
76
|
+
for line in detail.splitlines():
|
|
77
|
+
line = line.strip().strip(".")
|
|
78
|
+
if line:
|
|
79
|
+
p = line.partition(":")
|
|
80
|
+
if p[2] and p[0] in ("git", "error", "fatal"):
|
|
81
|
+
prefixed.append(p[2].strip())
|
|
82
|
+
|
|
83
|
+
else:
|
|
84
|
+
lines.append(line)
|
|
85
|
+
|
|
86
|
+
return " ".join((prefixed or lines)[:2]).replace(" ", " ").strip()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class GitRunReport:
|
|
90
|
+
"""Convenient and easy to compose reporting class"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
other: GitRunReport | None = None,
|
|
95
|
+
*,
|
|
96
|
+
progress: str | None = None,
|
|
97
|
+
note: str | None = None,
|
|
98
|
+
problem: str | None = None,
|
|
99
|
+
):
|
|
100
|
+
self._progress = []
|
|
101
|
+
self._note = []
|
|
102
|
+
self._problem = []
|
|
103
|
+
self.add(other, progress=progress, note=note, problem=problem)
|
|
104
|
+
|
|
105
|
+
def __str__(self):
|
|
106
|
+
return self.representation()
|
|
107
|
+
|
|
108
|
+
def require_success(self, operation: str) -> GitRunReport:
|
|
109
|
+
if self.has_problems:
|
|
110
|
+
Reporter.abort(self.add(problem=f"<can't {operation}"))
|
|
111
|
+
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def cant_pull(self, reason=None):
|
|
115
|
+
self.add(problem="<can't pull")
|
|
116
|
+
if reason:
|
|
117
|
+
self.add(problem=reason)
|
|
118
|
+
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def has_problems(self):
|
|
123
|
+
return bool(self._problem)
|
|
124
|
+
|
|
125
|
+
def representation(self, progress=True, note=True, max_chars=160, separator="; "):
|
|
126
|
+
"""
|
|
127
|
+
:param bool progress: Show repos with progress mention (pulled/cloned)
|
|
128
|
+
:param bool note: Show repos with notes
|
|
129
|
+
:param int max_chars: Max chars to show (truncate if messages are longer)
|
|
130
|
+
:param str separator: Separator to use
|
|
131
|
+
:return str: Textual representation
|
|
132
|
+
"""
|
|
133
|
+
result = []
|
|
134
|
+
n = _add_sorted(result, self._problem, Reporter.problem, 0, max_chars)
|
|
135
|
+
|
|
136
|
+
if progress:
|
|
137
|
+
n = _add_sorted(result, self._progress, Reporter.progress, n, max_chars)
|
|
138
|
+
|
|
139
|
+
if note:
|
|
140
|
+
_add_sorted(result, self._note, Reporter.note, n, max_chars)
|
|
141
|
+
|
|
142
|
+
result = separator.join(result)
|
|
143
|
+
if len(result) > max_chars:
|
|
144
|
+
result = f"{result[: max_chars - 3]}..."
|
|
145
|
+
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
def add(
|
|
149
|
+
self,
|
|
150
|
+
other: GitRunReport | None = None,
|
|
151
|
+
*,
|
|
152
|
+
progress: str | None = None,
|
|
153
|
+
note: str | None = None,
|
|
154
|
+
problem: str | None = None,
|
|
155
|
+
) -> GitRunReport:
|
|
156
|
+
if other is not None:
|
|
157
|
+
_add_messages(self._progress, other._progress)
|
|
158
|
+
_add_messages(self._note, other._note)
|
|
159
|
+
_add_messages(self._problem, other._problem)
|
|
160
|
+
|
|
161
|
+
_add_messages(self._progress, progress)
|
|
162
|
+
_add_messages(self._note, note)
|
|
163
|
+
_add_messages(self._problem, problem)
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def git_error_report(proc: subprocess.CompletedProcess[str]) -> GitRunReport:
|
|
168
|
+
"""GitRunReport describing a failed git command."""
|
|
169
|
+
return GitRunReport(problem=git_error_message(proc))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class BranchUpstream:
|
|
174
|
+
"""Configured upstream for a local branch."""
|
|
175
|
+
|
|
176
|
+
remote: str
|
|
177
|
+
branch: str
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class CleanableLocalBranch:
|
|
182
|
+
"""Local branch that is safe to delete."""
|
|
183
|
+
|
|
184
|
+
name: str
|
|
185
|
+
force_delete: bool = False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class CleanableRemoteBranch:
|
|
190
|
+
"""Tracked remote branch that is safe to delete with an exact-ref lease."""
|
|
191
|
+
|
|
192
|
+
remote: str
|
|
193
|
+
branch: str
|
|
194
|
+
expected_oid: str
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class GitRefs:
|
|
198
|
+
"""Repository ref and upstream snapshot."""
|
|
199
|
+
|
|
200
|
+
current: str
|
|
201
|
+
detached: bool
|
|
202
|
+
local: set[str]
|
|
203
|
+
remotes: list[str]
|
|
204
|
+
by_remote: dict[str, set[str]]
|
|
205
|
+
default_branches: dict[str, str]
|
|
206
|
+
upstreams: dict[str, BranchUpstream]
|
|
207
|
+
|
|
208
|
+
def __init__(self, parent: GitDir):
|
|
209
|
+
self.current, self.detached = parent._current_branch()
|
|
210
|
+
self.local = set()
|
|
211
|
+
self.remotes = parent.checked_git_command_lines("remote")
|
|
212
|
+
self.by_remote = {}
|
|
213
|
+
self.default_branches = {}
|
|
214
|
+
self.upstreams = {}
|
|
215
|
+
lines = parent.checked_git_command_lines(
|
|
216
|
+
"for-each-ref",
|
|
217
|
+
"--format=%(refname)%09%(upstream:remotename)%09%(upstream:remoteref)%09%(symref)",
|
|
218
|
+
"refs/heads",
|
|
219
|
+
"refs/remotes",
|
|
220
|
+
)
|
|
221
|
+
for line in lines:
|
|
222
|
+
self._add_line(line)
|
|
223
|
+
|
|
224
|
+
def _add_line(self, line: str):
|
|
225
|
+
fields = (line + "\t" * 3).split("\t")
|
|
226
|
+
refname, upstream_remote, upstream_ref, symref = fields[:4]
|
|
227
|
+
if refname.startswith(LOCAL_REF_PREFIX):
|
|
228
|
+
local_branch_name = refname[len(LOCAL_REF_PREFIX) :]
|
|
229
|
+
self.local.add(local_branch_name)
|
|
230
|
+
|
|
231
|
+
if upstream_remote and upstream_ref:
|
|
232
|
+
upstream_branch = upstream_ref[len(LOCAL_REF_PREFIX) :] if upstream_ref.startswith(LOCAL_REF_PREFIX) else upstream_ref
|
|
233
|
+
upstream = BranchUpstream(remote=upstream_remote, branch=upstream_branch)
|
|
234
|
+
self.upstreams[local_branch_name] = upstream
|
|
235
|
+
|
|
236
|
+
elif refname.startswith(REMOTE_REF_PREFIX):
|
|
237
|
+
remote, _, branch = refname[len(REMOTE_REF_PREFIX) :].partition("/")
|
|
238
|
+
if remote and branch:
|
|
239
|
+
if branch == "HEAD":
|
|
240
|
+
prefix = f"{REMOTE_REF_PREFIX}{remote}/"
|
|
241
|
+
if len(symref) > len(prefix) and symref.startswith(prefix):
|
|
242
|
+
self.default_branches[remote] = symref[len(prefix) :]
|
|
243
|
+
|
|
244
|
+
else:
|
|
245
|
+
self.by_remote.setdefault(remote, set()).add(branch)
|
|
246
|
+
|
|
247
|
+
def has_remote_branch(self, remote: str, branch: str) -> bool:
|
|
248
|
+
remote_branches = self.by_remote.get(remote)
|
|
249
|
+
return bool(remote_branches and (branch in remote_branches))
|
|
250
|
+
|
|
251
|
+
def upstream_gone(self, branch=None) -> bool:
|
|
252
|
+
upstream = self.upstreams.get(branch or self.current)
|
|
253
|
+
return bool(upstream and not self.has_remote_branch(upstream.remote, upstream.branch))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class GitDir:
|
|
257
|
+
"""Model a local git repo"""
|
|
258
|
+
|
|
259
|
+
def __init__(self, path: Path):
|
|
260
|
+
"""
|
|
261
|
+
:param Path path: Path to local repo
|
|
262
|
+
"""
|
|
263
|
+
self.path = path
|
|
264
|
+
self.basename = path.name
|
|
265
|
+
self.age = self._current_age()
|
|
266
|
+
|
|
267
|
+
def _branch_annotations(self, name):
|
|
268
|
+
return Reporter.joined(
|
|
269
|
+
name == self.default_branch and Reporter.branch_default("[default]"),
|
|
270
|
+
self.is_orphan_branch(name) and Reporter.branch_orphaned("[orphaned]"),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _detail_lines(items, state_style, worktree_style=None) -> list[str]:
|
|
275
|
+
lines = []
|
|
276
|
+
for item in items:
|
|
277
|
+
state = item[0:2]
|
|
278
|
+
if worktree_style:
|
|
279
|
+
state = f"{state_style(item[0])}{worktree_style(item[1])}"
|
|
280
|
+
|
|
281
|
+
elif state_style:
|
|
282
|
+
state = state_style(state)
|
|
283
|
+
|
|
284
|
+
lines.append(f"{state} {item[3:]}")
|
|
285
|
+
|
|
286
|
+
return lines
|
|
287
|
+
|
|
288
|
+
def status_line(self, report: GitRunReport | None = None) -> str:
|
|
289
|
+
refs = self.refs
|
|
290
|
+
status = self.status
|
|
291
|
+
edits, deletes, new = status.pending_change_counts()
|
|
292
|
+
report = GitRunReport(report)
|
|
293
|
+
if self.age is not None and self.age > FRESHNESS_THRESHOLD:
|
|
294
|
+
report.add(note=f"⌛{runez.represented_duration(self.age)}")
|
|
295
|
+
|
|
296
|
+
other_branches = refs.local - {refs.current, self.default_branch}
|
|
297
|
+
cleanable = len(other_branches & self.local_cleanable_branches)
|
|
298
|
+
remaining = len(other_branches) - cleanable
|
|
299
|
+
branch_summary = Reporter.joined(cleanable and f"+{cleanable}🪦", remaining and f"+{remaining}", separator="")
|
|
300
|
+
if branch_summary:
|
|
301
|
+
branch_summary = f"[{branch_summary}]"
|
|
302
|
+
|
|
303
|
+
return Reporter.joined(
|
|
304
|
+
self.represented_current_branch(),
|
|
305
|
+
branch_summary,
|
|
306
|
+
status.upstream_delta(),
|
|
307
|
+
edits and f"✏️{edits}",
|
|
308
|
+
deletes and f"🗑️{deletes}",
|
|
309
|
+
new and f"🆕{new}",
|
|
310
|
+
report,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def represented_current_branch(self) -> str:
|
|
314
|
+
refs = self.refs
|
|
315
|
+
branch = refs.current
|
|
316
|
+
if refs.detached:
|
|
317
|
+
icon = " 👻"
|
|
318
|
+
|
|
319
|
+
elif self.is_orphan_branch(branch):
|
|
320
|
+
icon = " 🪦"
|
|
321
|
+
|
|
322
|
+
else:
|
|
323
|
+
icon = " ✅" if self.age is not None and self.age <= FRESH_FETCH_THRESHOLD else " ☑️"
|
|
324
|
+
|
|
325
|
+
return self.represented_branch(branch) + icon
|
|
326
|
+
|
|
327
|
+
def status_details(self, indent=" ") -> str:
|
|
328
|
+
result = []
|
|
329
|
+
orphan_branches = [branch for branch in self.orphan_branches if branch != self.refs.current and self.is_orphan_branch(branch)]
|
|
330
|
+
if orphan_branches:
|
|
331
|
+
result.append(f"Orphan branches: {', '.join(self.represented_branch(branch) for branch in orphan_branches)}")
|
|
332
|
+
|
|
333
|
+
status = self.status
|
|
334
|
+
result.extend(self._detail_lines(status.modified, Reporter.index_change, Reporter.worktree_change))
|
|
335
|
+
result.extend(self._detail_lines(status.untracked, Reporter.untracked_change))
|
|
336
|
+
return Reporter.joined_lines(result, indent=indent)
|
|
337
|
+
|
|
338
|
+
def branch_details(self, indent="") -> str:
|
|
339
|
+
refs = self.refs
|
|
340
|
+
branches = sorted(refs.local) or [refs.current]
|
|
341
|
+
width = max(len(name) for name in branches)
|
|
342
|
+
result = []
|
|
343
|
+
for name in branches:
|
|
344
|
+
padding = " " * (width - len(name))
|
|
345
|
+
line = f"{'*' if name == refs.current else ' '} {self.represented_branch(name)}{padding}"
|
|
346
|
+
annotations = self._branch_annotations(name)
|
|
347
|
+
if annotations:
|
|
348
|
+
line += f" {annotations}"
|
|
349
|
+
|
|
350
|
+
result.append(line)
|
|
351
|
+
|
|
352
|
+
return Reporter.joined_lines(result, indent=indent)
|
|
353
|
+
|
|
354
|
+
def run_git_command(self, *args: str) -> subprocess.CompletedProcess[str]:
|
|
355
|
+
"""
|
|
356
|
+
:param args: Execute git command with provided args
|
|
357
|
+
:return subprocess.CompletedProcess: Result from git
|
|
358
|
+
"""
|
|
359
|
+
cmd = ["git", "-C", str(self.path), *args]
|
|
360
|
+
Reporter.debug("Running: git -C %s %s", runez.short(self.path), " ".join(args))
|
|
361
|
+
return subprocess.run(cmd, check=False, capture_output=True, text=True) # noqa: S603
|
|
362
|
+
|
|
363
|
+
def checked_git_command(self, *args: str) -> str:
|
|
364
|
+
"""
|
|
365
|
+
:param args: Execute git command with provided args
|
|
366
|
+
:return str: Output from git command, aborting if git fails
|
|
367
|
+
"""
|
|
368
|
+
proc = self.run_git_command(*args)
|
|
369
|
+
Reporter.abort_if(proc.returncode, f"git {' '.join(args)} failed: {git_error_message(proc)}")
|
|
370
|
+
return proc.stdout.strip()
|
|
371
|
+
|
|
372
|
+
def checked_git_command_lines(self, *args: str) -> list[str]:
|
|
373
|
+
return [line.strip() for line in self.checked_git_command(*args).splitlines() if line.strip()]
|
|
374
|
+
|
|
375
|
+
def clear_cached_state(self):
|
|
376
|
+
"""Discard cached git state after a command may have changed refs or worktree state."""
|
|
377
|
+
for k, v in vars(self.__class__).items():
|
|
378
|
+
if isinstance(v, cached_property) and k in self.__dict__:
|
|
379
|
+
self.__dict__.pop(k, None)
|
|
380
|
+
|
|
381
|
+
def _current_branch(self) -> tuple[str, bool]:
|
|
382
|
+
proc = self.run_git_command("symbolic-ref", "--quiet", "--short", "HEAD")
|
|
383
|
+
if proc.returncode == 0:
|
|
384
|
+
return proc.stdout.strip(), False
|
|
385
|
+
|
|
386
|
+
Reporter.abort_if(proc.returncode != 1, f"Could not inspect git branch: {git_error_message(proc)}")
|
|
387
|
+
proc = self.run_git_command("rev-parse", "--verify", "--quiet", "HEAD")
|
|
388
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
389
|
+
return "HEAD", True
|
|
390
|
+
|
|
391
|
+
Reporter.abort(f"Could not inspect git HEAD: {git_error_message(proc)}")
|
|
392
|
+
|
|
393
|
+
def fetch_now(self) -> GitRunReport:
|
|
394
|
+
proc = self.run_git_command("fetch", "--all", "--prune")
|
|
395
|
+
if proc.returncode == 0:
|
|
396
|
+
self.age = 0
|
|
397
|
+
|
|
398
|
+
return git_error_report(proc)
|
|
399
|
+
|
|
400
|
+
def checkout_default_branch(self) -> GitRunReport:
|
|
401
|
+
"""
|
|
402
|
+
:return GitRunReport: Checkout report
|
|
403
|
+
"""
|
|
404
|
+
branch = self.default_branch
|
|
405
|
+
if self.refs.current == branch:
|
|
406
|
+
return GitRunReport()
|
|
407
|
+
|
|
408
|
+
self.checked_git_command("checkout", branch)
|
|
409
|
+
self.clear_cached_state()
|
|
410
|
+
return GitRunReport(progress=f"checked out {self.represented_branch(branch)}")
|
|
411
|
+
|
|
412
|
+
def pull(self) -> GitRunReport:
|
|
413
|
+
"""Pull from tracked remote"""
|
|
414
|
+
refs = self.refs
|
|
415
|
+
if not refs.remotes:
|
|
416
|
+
return GitRunReport().cant_pull("no remotes")
|
|
417
|
+
|
|
418
|
+
if refs.detached:
|
|
419
|
+
return GitRunReport().cant_pull("HEAD detached")
|
|
420
|
+
|
|
421
|
+
status = self.status
|
|
422
|
+
if status.has_pending_changes or status.report.has_problems:
|
|
423
|
+
reason = "pending changes" if status.has_pending_changes else None
|
|
424
|
+
return GitRunReport(status.report).cant_pull(reason)
|
|
425
|
+
|
|
426
|
+
note = status.upstream_delta() or "up-to-date"
|
|
427
|
+
proc = self.run_git_command("pull", "--rebase")
|
|
428
|
+
self.clear_cached_state()
|
|
429
|
+
report = GitRunReport(note=f"was {note}")
|
|
430
|
+
if proc.returncode:
|
|
431
|
+
report.cant_pull(git_error_message(proc))
|
|
432
|
+
|
|
433
|
+
else:
|
|
434
|
+
self.age = 0
|
|
435
|
+
|
|
436
|
+
return report
|
|
437
|
+
|
|
438
|
+
def _current_age(self) -> int | None:
|
|
439
|
+
"""Elapsed time in seconds since last fetch"""
|
|
440
|
+
for name in ("FETCH_HEAD", "HEAD"):
|
|
441
|
+
try:
|
|
442
|
+
last_fetch = (self.path / ".git" / name).stat().st_mtime
|
|
443
|
+
return int(time.time() - last_fetch)
|
|
444
|
+
|
|
445
|
+
except OSError:
|
|
446
|
+
pass
|
|
447
|
+
|
|
448
|
+
@cached_property
|
|
449
|
+
def default_branch(self) -> str:
|
|
450
|
+
"""Default branch name"""
|
|
451
|
+
refs = self.refs
|
|
452
|
+
branch = refs.default_branches.get("origin")
|
|
453
|
+
if branch:
|
|
454
|
+
return branch
|
|
455
|
+
|
|
456
|
+
origin_branches = refs.by_remote.get("origin")
|
|
457
|
+
if origin_branches:
|
|
458
|
+
for candidate in ("main", "master"):
|
|
459
|
+
if candidate in refs.local or candidate in origin_branches:
|
|
460
|
+
return candidate
|
|
461
|
+
|
|
462
|
+
return "main"
|
|
463
|
+
|
|
464
|
+
@cached_property
|
|
465
|
+
def status(self) -> GitStatus:
|
|
466
|
+
"""Parsed info from 'git status --porcelain=v2 --branch'"""
|
|
467
|
+
return GitStatus(self)
|
|
468
|
+
|
|
469
|
+
@cached_property
|
|
470
|
+
def refs(self) -> GitRefs:
|
|
471
|
+
return GitRefs(self)
|
|
472
|
+
|
|
473
|
+
@cached_property
|
|
474
|
+
def orphan_branches(self) -> list[str]:
|
|
475
|
+
"""Local branch names that were deleted on their corresponding remote"""
|
|
476
|
+
result = []
|
|
477
|
+
refs = self.refs
|
|
478
|
+
for name in sorted(refs.local):
|
|
479
|
+
upstream = refs.upstreams.get(name)
|
|
480
|
+
if not upstream or not refs.has_remote_branch(upstream.remote, upstream.branch):
|
|
481
|
+
result.append(name)
|
|
482
|
+
|
|
483
|
+
return result
|
|
484
|
+
|
|
485
|
+
def is_protected_branch(self, name: str) -> bool:
|
|
486
|
+
"""True if branch should not be cleaned or reported as orphaned"""
|
|
487
|
+
return bool(name and (name == self.default_branch or name in self.refs.default_branches.values()))
|
|
488
|
+
|
|
489
|
+
def is_orphan_branch(self, name: str) -> bool:
|
|
490
|
+
"""True if branch is an unprotected orphan."""
|
|
491
|
+
return bool(name and name in self.orphan_branches and not self.is_protected_branch(name))
|
|
492
|
+
|
|
493
|
+
def represented_branch(self, name: str) -> str:
|
|
494
|
+
"""Styled representation of a branch name."""
|
|
495
|
+
result = runez.bold(name)
|
|
496
|
+
if name == self.default_branch:
|
|
497
|
+
return runez.green(result)
|
|
498
|
+
|
|
499
|
+
if self.is_orphan_branch(name):
|
|
500
|
+
return runez.orange(result)
|
|
501
|
+
|
|
502
|
+
return result
|
|
503
|
+
|
|
504
|
+
@cached_property
|
|
505
|
+
def cleanable_base_ref(self) -> str:
|
|
506
|
+
"""Ref that cleanup candidates must already be merged into"""
|
|
507
|
+
base_ref = self.default_branch
|
|
508
|
+
remote_branches = self.refs.by_remote.get("origin")
|
|
509
|
+
if remote_branches and base_ref in remote_branches:
|
|
510
|
+
base_ref = f"origin/{base_ref}"
|
|
511
|
+
|
|
512
|
+
return base_ref
|
|
513
|
+
|
|
514
|
+
def is_ancestor(self, ref: str, target_ref: str) -> bool:
|
|
515
|
+
"""
|
|
516
|
+
:param str ref: Candidate ref
|
|
517
|
+
:param str target_ref: Ref that should contain the candidate
|
|
518
|
+
:return bool: True if 'ref' is an ancestor of 'target_ref'
|
|
519
|
+
"""
|
|
520
|
+
proc = self.run_git_command("merge-base", "--is-ancestor", ref, target_ref)
|
|
521
|
+
Reporter.abort_if(proc.returncode > 1, f"Could not inspect git ancestry: {git_error_message(proc)}")
|
|
522
|
+
return proc.returncode == 0
|
|
523
|
+
|
|
524
|
+
def merge_is_noop(self, ref: str, target_ref: str) -> bool:
|
|
525
|
+
"""
|
|
526
|
+
:param str ref: Candidate ref
|
|
527
|
+
:param str target_ref: Ref that should already contain the candidate content
|
|
528
|
+
:return bool: True if merging 'ref' into 'target_ref' would leave 'target_ref' unchanged
|
|
529
|
+
"""
|
|
530
|
+
target_tree = self.checked_git_command("rev-parse", f"{target_ref}^{{tree}}")
|
|
531
|
+
proc = self.run_git_command("merge-tree", "--write-tree", "--no-messages", target_ref, ref)
|
|
532
|
+
if proc.returncode:
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
trees = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
|
|
536
|
+
return len(trees) == 1 and bool(target_tree) and trees[0] == target_tree
|
|
537
|
+
|
|
538
|
+
def cleanable_local_branch(self, name: str, include_current=False) -> CleanableLocalBranch | None:
|
|
539
|
+
"""
|
|
540
|
+
:param str name: Local branch name
|
|
541
|
+
:param bool include_current: If True, allow checking the current branch
|
|
542
|
+
:return CleanableLocalBranch|None: Cleanup details if local branch is safe to clean
|
|
543
|
+
"""
|
|
544
|
+
base_ref = self.cleanable_base_ref
|
|
545
|
+
refs = self.refs
|
|
546
|
+
if not base_ref or not name or self.is_protected_branch(name) or (not include_current and name == refs.current):
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
if self.is_ancestor(name, base_ref):
|
|
550
|
+
return CleanableLocalBranch(name)
|
|
551
|
+
|
|
552
|
+
if self.merge_is_noop(name, base_ref):
|
|
553
|
+
return CleanableLocalBranch(name, force_delete=True)
|
|
554
|
+
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
def cleanable_current_remote_branch(self) -> CleanableRemoteBranch | None:
|
|
558
|
+
"""Return cleanup details for the current branch's safely deletable origin ref."""
|
|
559
|
+
refs = self.refs
|
|
560
|
+
upstream = refs.upstreams.get(refs.current)
|
|
561
|
+
default_branch = self.default_branch
|
|
562
|
+
if (
|
|
563
|
+
not upstream
|
|
564
|
+
or upstream.remote != "origin"
|
|
565
|
+
or upstream.branch == default_branch
|
|
566
|
+
or not refs.has_remote_branch(upstream.remote, upstream.branch)
|
|
567
|
+
or not refs.has_remote_branch(upstream.remote, default_branch)
|
|
568
|
+
):
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
branch_ref = f"{REMOTE_REF_PREFIX}{upstream.remote}/{upstream.branch}"
|
|
572
|
+
base_ref = f"{REMOTE_REF_PREFIX}{upstream.remote}/{default_branch}"
|
|
573
|
+
if not self.is_ancestor(branch_ref, base_ref) and not self.merge_is_noop(branch_ref, base_ref):
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
expected_oid = self.checked_git_command("rev-parse", branch_ref)
|
|
577
|
+
return CleanableRemoteBranch(upstream.remote, upstream.branch, expected_oid)
|
|
578
|
+
|
|
579
|
+
@cached_property
|
|
580
|
+
def local_cleanable_branches(self) -> set[str]:
|
|
581
|
+
"""
|
|
582
|
+
:return set: Local branches that can be cleaned
|
|
583
|
+
"""
|
|
584
|
+
return {name for name in self.refs.local if self.cleanable_local_branch(name)}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class GitStatus:
|
|
588
|
+
"""Currently modified files"""
|
|
589
|
+
|
|
590
|
+
def __init__(self, parent: GitDir):
|
|
591
|
+
self.ahead = 0
|
|
592
|
+
self.behind = 0
|
|
593
|
+
self.modified = []
|
|
594
|
+
self.untracked = []
|
|
595
|
+
self.report = GitRunReport()
|
|
596
|
+
lines = parent.checked_git_command_lines("status", "--porcelain=v2", "--branch")
|
|
597
|
+
for line in lines:
|
|
598
|
+
prefix = line[0]
|
|
599
|
+
info = line[2:]
|
|
600
|
+
if prefix == "#":
|
|
601
|
+
keyword, _, value = info.partition(" ")
|
|
602
|
+
if keyword == "branch.ab":
|
|
603
|
+
ab = value.partition(" ")
|
|
604
|
+
self.ahead = int(ab[0])
|
|
605
|
+
self.behind = -int(ab[2])
|
|
606
|
+
|
|
607
|
+
elif prefix == "?":
|
|
608
|
+
self.untracked.append(f" ? {info}")
|
|
609
|
+
|
|
610
|
+
else:
|
|
611
|
+
fields = info.split(" ")
|
|
612
|
+
state = fields[0].replace(".", " ")
|
|
613
|
+
path = fields[-1].partition("\t")[0]
|
|
614
|
+
self.modified.append(f"{state} {path}")
|
|
615
|
+
|
|
616
|
+
if parent.refs.upstream_gone():
|
|
617
|
+
self.report.add(problem="remote branch gone")
|
|
618
|
+
|
|
619
|
+
@property
|
|
620
|
+
def dirty_note(self) -> str:
|
|
621
|
+
"""Short overview of pending changes."""
|
|
622
|
+
return Reporter.joined(
|
|
623
|
+
self.modified and Reporter.problem(runez.plural(self.modified, "diff")),
|
|
624
|
+
self.untracked and Reporter.untracked_change(f"{len(self.untracked)} untracked"),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
@property
|
|
628
|
+
def has_pending_changes(self) -> bool:
|
|
629
|
+
return bool(self.modified or self.untracked)
|
|
630
|
+
|
|
631
|
+
def upstream_delta(self) -> str:
|
|
632
|
+
"""Short report on divergence from the upstream branch."""
|
|
633
|
+
return Reporter.joined(
|
|
634
|
+
self.ahead and Reporter.note(f"{self.ahead} ahead"),
|
|
635
|
+
self.behind and Reporter.note(f"{self.behind} behind"),
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
def pending_change_counts(self) -> tuple[int, int, int]:
|
|
639
|
+
edits = 0
|
|
640
|
+
deletes = 0
|
|
641
|
+
new = len(self.untracked)
|
|
642
|
+
for item in self.modified:
|
|
643
|
+
state = item[0:2]
|
|
644
|
+
if "D" in state:
|
|
645
|
+
deletes += 1
|
|
646
|
+
|
|
647
|
+
elif "A" in state:
|
|
648
|
+
new += 1
|
|
649
|
+
|
|
650
|
+
else:
|
|
651
|
+
edits += 1
|
|
652
|
+
|
|
653
|
+
return edits, deletes, new
|
|
654
|
+
|
|
655
|
+
def require_clean(self, operation: str):
|
|
656
|
+
if self.has_pending_changes:
|
|
657
|
+
Reporter.abort(f"can't {operation}: pending changes: {self.dirty_note}")
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _add_messages(target: list[str], messages: str | list[str] | None):
|
|
661
|
+
if messages:
|
|
662
|
+
if not isinstance(messages, list):
|
|
663
|
+
messages = [messages]
|
|
664
|
+
|
|
665
|
+
for message in messages:
|
|
666
|
+
if message not in target:
|
|
667
|
+
target.append(message)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _add_sorted(result, target, color, n, max_chars) -> int:
|
|
671
|
+
"""
|
|
672
|
+
:param list(str) result: Where to accumulate sorted report
|
|
673
|
+
:param list(str) target: Target to sort, respecting '<' and '>' prefixing
|
|
674
|
+
:param color: Optional color to use
|
|
675
|
+
:param int n: How many chars were consumed so far
|
|
676
|
+
:param int|None max_chars: Maximum number of characters to yield
|
|
677
|
+
:return int: Number of chars accumulated
|
|
678
|
+
"""
|
|
679
|
+
if max_chars and n > max_chars:
|
|
680
|
+
# We already reached limit
|
|
681
|
+
return n
|
|
682
|
+
|
|
683
|
+
items = []
|
|
684
|
+
for message in (s.lstrip("<>") for i, s in sorted(enumerate(target), key=_report_sorter)):
|
|
685
|
+
size = len(message)
|
|
686
|
+
if max_chars:
|
|
687
|
+
remaining = max_chars - n
|
|
688
|
+
if remaining < size:
|
|
689
|
+
items.append(message[:remaining])
|
|
690
|
+
n += size
|
|
691
|
+
break
|
|
692
|
+
|
|
693
|
+
n += size
|
|
694
|
+
items.append(message)
|
|
695
|
+
|
|
696
|
+
result.extend(color(s) for s in items)
|
|
697
|
+
return n
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _report_sorter(enum):
|
|
701
|
+
"""
|
|
702
|
+
:param tuple(int, str) enum: Tuple from enumerate()
|
|
703
|
+
:return int: Value to use for sorting messages in this report
|
|
704
|
+
"""
|
|
705
|
+
_, message = enum
|
|
706
|
+
if message[0] == "<":
|
|
707
|
+
return -enum[0] # '<' makes message sort towards front, but keeping order with other such prefixed messages
|
|
708
|
+
|
|
709
|
+
if message[0] == ">":
|
|
710
|
+
return 1000000 + enum[0] # '>' makes message sort towards end
|
|
711
|
+
|
|
712
|
+
return enum[0] # Non-prefixed message stay where they were
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mgit
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Fetch collections of git projects
|
|
5
|
+
Author-email: Zoran Simic <zoran@simicweb.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Source, https://github.com/zsimic/mgit
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Operating System :: Unix
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/x-rst
|
|
25
|
+
Requires-Dist: runez<6
|
|
26
|
+
|
|
27
|
+
mgit
|
|
28
|
+
====
|
|
29
|
+
|
|
30
|
+
A small companion for humans who like tidy git checkouts.
|
|
31
|
+
|
|
32
|
+
.. image:: https://img.shields.io/pypi/v/mgit.svg
|
|
33
|
+
:target: https://pypi.org/project/mgit/
|
|
34
|
+
:alt: Version on pypi
|
|
35
|
+
|
|
36
|
+
.. image:: https://github.com/zsimic/mgit/workflows/Tests/badge.svg
|
|
37
|
+
:target: https://github.com/zsimic/mgit/actions
|
|
38
|
+
:alt: Tested with Github Actions
|
|
39
|
+
|
|
40
|
+
.. image:: https://img.shields.io/pypi/pyversions/mgit.svg
|
|
41
|
+
:target: https://pypi.org/project/mgit/
|
|
42
|
+
:alt: Python versions tested
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
What it is
|
|
46
|
+
==========
|
|
47
|
+
|
|
48
|
+
``mgit`` is a tiny CLI for people who like their git checkouts clean, current,
|
|
49
|
+
and easy to scan. It gives you a quick read on one repo, or on every repo
|
|
50
|
+
directly inside a workspace folder.
|
|
51
|
+
|
|
52
|
+
- What state did I leave this checkout in?
|
|
53
|
+
- Can I pull, or did I leave work pending here?
|
|
54
|
+
- Is a merged branch still hanging around, waiting to be cleaned?
|
|
55
|
+
- I just wrapped up a branch; can I safely get back to ``main`` and clear it away?
|
|
56
|
+
|
|
57
|
+
``mgit`` is not a git replacement. It is the compact, careful layer for those
|
|
58
|
+
ordinary moments where you would rather see the answer than reconstruct it
|
|
59
|
+
from a handful of commands.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
The rhythm
|
|
63
|
+
==========
|
|
64
|
+
|
|
65
|
+
Start with a glance::
|
|
66
|
+
|
|
67
|
+
mgit # show status here, or across a workspace
|
|
68
|
+
|
|
69
|
+
Refresh or move forward only when you mean to::
|
|
70
|
+
|
|
71
|
+
mgit f # fetch --all --prune, then show the refreshed status
|
|
72
|
+
mgit p # pull --rebase, but never over pending work
|
|
73
|
+
|
|
74
|
+
And for the wonderfully common ``PR merged`` moment::
|
|
75
|
+
|
|
76
|
+
mgit g # fetch, return to the default branch, pull, clean this branch
|
|
77
|
+
|
|
78
|
+
The short names are the intended interface:
|
|
79
|
+
|
|
80
|
+
- ``status`` / ``s``: show whether anything here needs attention. This is the default.
|
|
81
|
+
- ``fetch`` / ``f``: refresh remote refs, then show what changed.
|
|
82
|
+
- ``pull`` / ``p``: pull with rebase only when pending work will not be disturbed.
|
|
83
|
+
- ``main`` / ``m``: checkout the default branch, even if it is ``master``.
|
|
84
|
+
- ``branches`` / ``b``: list local branches with useful small annotations.
|
|
85
|
+
- ``groom`` / ``g``: safely finish with the merged branch you are currently on.
|
|
86
|
+
|
|
87
|
+
The command shape is::
|
|
88
|
+
|
|
89
|
+
mgit [-v] [--color auto|always|never] [COMMAND] [FOLDER]
|
|
90
|
+
|
|
91
|
+
You can pass a folder to most commands::
|
|
92
|
+
|
|
93
|
+
mgit ~/github
|
|
94
|
+
mgit f ~/github
|
|
95
|
+
mgit g ~/github/mgit
|
|
96
|
+
|
|
97
|
+
Workspace scans are shallow on purpose: ``mgit ~/github`` inspects direct
|
|
98
|
+
children like ``~/github/*/.git`` and does not crawl nested folders.
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
What you see
|
|
102
|
+
============
|
|
103
|
+
|
|
104
|
+
A workspace stays pleasantly skimmable::
|
|
105
|
+
|
|
106
|
+
mgit: main ✅
|
|
107
|
+
pickley: main ☑️ [+1🪦] ⌛4w 6d
|
|
108
|
+
runez: feature 🪦 ✏️1
|
|
109
|
+
detached: HEAD 👻
|
|
110
|
+
|
|
111
|
+
In one glance you get:
|
|
112
|
+
|
|
113
|
+
- the current branch
|
|
114
|
+
- local diffs and untracked files
|
|
115
|
+
- ahead/behind/gone tracking state
|
|
116
|
+
- fetch freshness
|
|
117
|
+
- a compact hint that a stale local branch is still lurking
|
|
118
|
+
|
|
119
|
+
``✅`` means recently refreshed, ``☑️`` means the local picture may be older,
|
|
120
|
+
``⌛`` calls out notably stale fetch information, a branch-level ``🪦`` means
|
|
121
|
+
its upstream is gone, and ``👻`` means detached ``HEAD``. A bracket such as
|
|
122
|
+
``[+2🪦+1]`` says that, besides the current and default branches, two local
|
|
123
|
+
branches have been proven cleanable and one remains.
|
|
124
|
+
|
|
125
|
+
For a single checkout, status also prints the pending paths. Workspace output
|
|
126
|
+
keeps to one line per repo, so you can keep checking a directory full of
|
|
127
|
+
projects without turning the terminal into a log dump::
|
|
128
|
+
|
|
129
|
+
mgit ~/github/mgit
|
|
130
|
+
|
|
131
|
+
Color is automatic on terminals and can be controlled explicitly::
|
|
132
|
+
|
|
133
|
+
mgit --color auto
|
|
134
|
+
mgit --color always
|
|
135
|
+
mgit --color never
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
Safety model
|
|
139
|
+
============
|
|
140
|
+
|
|
141
|
+
``mgit`` is read-only by default. Asking how things look does not change your repos.
|
|
142
|
+
|
|
143
|
+
Commands that act are explicit:
|
|
144
|
+
|
|
145
|
+
- ``mgit f`` updates local remote refs with ``git fetch --all --prune``.
|
|
146
|
+
- ``mgit p`` pulls with rebase only when the checkout passes safety checks.
|
|
147
|
+
- ``mgit g`` fetches, verifies that the current branch can be cleaned before
|
|
148
|
+
switching branches, pulls safely, and deletes that local branch only. If it
|
|
149
|
+
still exists on ``origin``, it deletes that one remote branch only after
|
|
150
|
+
independently proving that the fetched remote ref is merged or
|
|
151
|
+
content-equivalent to the fetched default branch; an exact-ref lease prevents
|
|
152
|
+
deleting a branch that advanced meanwhile. No other branch is deleted.
|
|
153
|
+
When already on the default branch, it fetches and reports that fact without
|
|
154
|
+
pulling or cleaning branches. Use ``mgit m`` when you only want to switch to
|
|
155
|
+
the default branch.
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
Coming next
|
|
159
|
+
===========
|
|
160
|
+
|
|
161
|
+
A larger convenience waiting in the wings is ``mgit clone``: give it a full
|
|
162
|
+
repo URL and let ``mgit`` choose the local destination from simple config
|
|
163
|
+
rules.
|
|
164
|
+
|
|
165
|
+
Planned config shape::
|
|
166
|
+
|
|
167
|
+
locations = [
|
|
168
|
+
{ match = "github.com/zsimic/*", dir = "~/github" },
|
|
169
|
+
{ match = "github.com/*", dir = "~/ext" },
|
|
170
|
+
{ match = "git.mycompany.com/*", dir = "~/dev" },
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
The goal is predictable placement without memorizing where each family of repos
|
|
174
|
+
belongs. Status, fetch, pull, main, and groom do not require any mgit config.
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
Install
|
|
178
|
+
=======
|
|
179
|
+
|
|
180
|
+
Install with pickley_ or pipx_::
|
|
181
|
+
|
|
182
|
+
pickley install mgit
|
|
183
|
+
|
|
184
|
+
or::
|
|
185
|
+
|
|
186
|
+
pipx install mgit
|
|
187
|
+
|
|
188
|
+
Install from a checkout for development::
|
|
189
|
+
|
|
190
|
+
git clone https://github.com/zsimic/mgit.git
|
|
191
|
+
cd mgit
|
|
192
|
+
uv sync
|
|
193
|
+
.venv/bin/mgit --help
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
Develop
|
|
197
|
+
=======
|
|
198
|
+
|
|
199
|
+
Fast local checks::
|
|
200
|
+
|
|
201
|
+
.venv/bin/pytest -q
|
|
202
|
+
ruff check
|
|
203
|
+
|
|
204
|
+
Full confidence check::
|
|
205
|
+
|
|
206
|
+
tox
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
.. _pickley: https://pypi.org/project/pickley/
|
|
210
|
+
|
|
211
|
+
.. _pipx: https://pypi.org/project/pipx/
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
mgit/__init__.py,sha256=WFknd94Qx8PF7nbKisHfTWCRIIB0dOMGGET4oAozdvQ,1222
|
|
2
|
+
mgit/cli.py,sha256=9txUWea8gXbOenLejisc21bnGv_FeEaLcSvnP4lM3Fc,11818
|
|
3
|
+
mgit/git.py,sha256=ohWVH4rSb0q9hhskuWr2BtAJ4Rab20Vv2aEFFh3y3Pk,24996
|
|
4
|
+
mgit-2.0.0.dist-info/METADATA,sha256=mySUrAEMWiWiTb5SWs_w1Wqhr_B-q6j1pyzpSNyScsk,6458
|
|
5
|
+
mgit-2.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
mgit-2.0.0.dist-info/entry_points.txt,sha256=v21pcJu2JM5QemmoJW0uFhodNmkexZo6SxLWSaEjDr4,39
|
|
7
|
+
mgit-2.0.0.dist-info/top_level.txt,sha256=CGzko63KesRtbSeVDtbZD5WEvyunRhTxnYStEDpeXvQ,5
|
|
8
|
+
mgit-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mgit
|