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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mgit = mgit.cli:main
@@ -0,0 +1 @@
1
+ mgit