rgit 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rgit/__init__.py +1 -0
- rgit/__main__.py +17 -0
- rgit/cli/__init__.py +74 -0
- rgit/cli/ignored.py +239 -0
- rgit/cli/registry.py +22 -0
- rgit/cli/scan.py +341 -0
- rgit/cli/status.py +669 -0
- rgit/configuration.py +64 -0
- rgit/constants.py +1 -0
- rgit/git.py +374 -0
- rgit/tools.py +356 -0
- rgit-0.0.1.dist-info/METADATA +33 -0
- rgit-0.0.1.dist-info/RECORD +16 -0
- rgit-0.0.1.dist-info/WHEEL +4 -0
- rgit-0.0.1.dist-info/entry_points.txt +2 -0
- rgit-0.0.1.dist-info/licenses/LICENSE +21 -0
rgit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import cli as _cli
|
rgit/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import sys, asyncio
|
|
2
|
+
from . import cli
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _smain(args):
|
|
6
|
+
asyncio.run(cli.main(args))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _ssmain():
|
|
10
|
+
try:
|
|
11
|
+
sys.exit(_smain(sys.argv[1:]))
|
|
12
|
+
except KeyboardInterrupt:
|
|
13
|
+
sys.stderr.write("\n")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
_ssmain()
|
rgit/cli/__init__.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import argparse, pathlib, sys
|
|
2
|
+
from .. import configuration, constants
|
|
3
|
+
from . import registry, scan, status, ignored
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def main(args):
|
|
7
|
+
opts = _parse_args(args=args)
|
|
8
|
+
config_path = find_config_file(opts)
|
|
9
|
+
config = await configuration.load(config_file_path=config_path)
|
|
10
|
+
handler = registry.get_command_handler(opts.command)
|
|
11
|
+
if handler is not None:
|
|
12
|
+
handler_instance = handler()
|
|
13
|
+
await handler_instance.execute(opts=opts, config=config)
|
|
14
|
+
else:
|
|
15
|
+
print("external commands are not supported yet")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_config_file(opts):
|
|
19
|
+
if opts.config_path:
|
|
20
|
+
return pathlib.Path(opts.config_path)
|
|
21
|
+
|
|
22
|
+
candidates = [
|
|
23
|
+
(pathlib.Path.home() / ("." + constants.SELF_NAME + ".json")),
|
|
24
|
+
(pathlib.Path.home() / ".config" / constants.SELF_NAME / "config.json"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
for c in candidates:
|
|
28
|
+
if c.exists():
|
|
29
|
+
return c
|
|
30
|
+
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_args(args=None):
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog=constants.SELF_NAME,
|
|
37
|
+
description=None,
|
|
38
|
+
epilog=None
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--config-path",
|
|
43
|
+
dest="config_path",
|
|
44
|
+
action="store",
|
|
45
|
+
metavar="PATH",
|
|
46
|
+
default=None,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--dont-show-progress",
|
|
51
|
+
dest="show_progress",
|
|
52
|
+
action="store_false",
|
|
53
|
+
default=sys.stderr.isatty(),
|
|
54
|
+
help="do not display display intermediary messages while executing",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
subparsers = parser.add_subparsers(
|
|
58
|
+
title=None,
|
|
59
|
+
dest="command",
|
|
60
|
+
metavar="COMMAND",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
for name, aliases, handler, disabled in registry.enumerate_command_handlers():
|
|
64
|
+
if disabled:
|
|
65
|
+
continue
|
|
66
|
+
handler.define_arguments(subparsers.add_parser(
|
|
67
|
+
name,
|
|
68
|
+
aliases=aliases,
|
|
69
|
+
help=handler.short_description()
|
|
70
|
+
))
|
|
71
|
+
|
|
72
|
+
opts = parser.parse_args(args)
|
|
73
|
+
|
|
74
|
+
return opts
|
rgit/cli/ignored.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import re, os, sys, pathlib
|
|
2
|
+
from .registry import command
|
|
3
|
+
from .. import git
|
|
4
|
+
from ..tools import is_path_in, path_relative_to_or_unchanged, strict_int, add_status_msg, set_status_msg, draw_table
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@command("ignored")
|
|
8
|
+
class Ignored(object):
|
|
9
|
+
@classmethod
|
|
10
|
+
def define_arguments(cls, parser):
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"--format", "-f",
|
|
13
|
+
dest="format",
|
|
14
|
+
action="store",
|
|
15
|
+
choices=["table", "json"],
|
|
16
|
+
default="table",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--group", "-g",
|
|
20
|
+
dest="groups",
|
|
21
|
+
action="append",
|
|
22
|
+
default=[],
|
|
23
|
+
help=(
|
|
24
|
+
"case-insensitive name of ignore group as defined in .gitignore files by putting "
|
|
25
|
+
"the group name immediately after \"#{{{\" which will include every pattern "
|
|
26
|
+
"until \"#}}}\""
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--not-in-group", "-x",
|
|
31
|
+
dest="not_in_groups",
|
|
32
|
+
action="append",
|
|
33
|
+
default=[],
|
|
34
|
+
help="",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--list", "-l",
|
|
38
|
+
dest="show_lists_only",
|
|
39
|
+
action="append",
|
|
40
|
+
metavar="NAME",
|
|
41
|
+
default=None,
|
|
42
|
+
choices=["groups", "sources", "files"],
|
|
43
|
+
help="only show the specified lists"
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"folders",
|
|
47
|
+
nargs="*",
|
|
48
|
+
metavar="FOLDER",
|
|
49
|
+
help="only inspect repositories worktrees of which are in one of specified folders"
|
|
50
|
+
)
|
|
51
|
+
@classmethod
|
|
52
|
+
def short_description(cls):
|
|
53
|
+
return "show and manipulate ignored files in repositories"
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
self._config = None
|
|
57
|
+
|
|
58
|
+
async def execute(self, *, opts, config):
|
|
59
|
+
self._config = config
|
|
60
|
+
|
|
61
|
+
opts.folders = [pathlib.Path(f).resolve() for f in opts.folders]
|
|
62
|
+
opts.groups = set(opts.groups)
|
|
63
|
+
opts.not_in_groups = set(opts.not_in_groups)
|
|
64
|
+
|
|
65
|
+
ignore_group_reader = IgnoreGroupReader()
|
|
66
|
+
|
|
67
|
+
results = {}
|
|
68
|
+
for repo in self._config.repositories:
|
|
69
|
+
if await git.is_bare(repo):
|
|
70
|
+
add_status_msg(".")
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
worktree_path = await git.toplevel(repo)
|
|
74
|
+
worktree_fspath = os.fspath(worktree_path)
|
|
75
|
+
|
|
76
|
+
if opts.folders and not any(is_path_in(f, worktree_path) for f in opts.folders):
|
|
77
|
+
add_status_msg("-")
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
add_status_msg("*")
|
|
81
|
+
|
|
82
|
+
ignored_files = [
|
|
83
|
+
path
|
|
84
|
+
for _, path in filter(
|
|
85
|
+
lambda x: x[0] == "ignored",
|
|
86
|
+
await git.status(repo, "--ignored=matching")
|
|
87
|
+
)
|
|
88
|
+
]
|
|
89
|
+
if not ignored_files:
|
|
90
|
+
continue
|
|
91
|
+
stdout = await git.git(repo,
|
|
92
|
+
"check-ignore", "-z", "--verbose", "--non-matching", "--stdin",
|
|
93
|
+
stdin="\0".join(ignored_files),
|
|
94
|
+
returncode_ok=lambda returncode: returncode in (0, 1),
|
|
95
|
+
worktree=git.TOPLEVEL,
|
|
96
|
+
# Ignored files are reported relative to repo work-tree, which check-ignore will
|
|
97
|
+
# resolve using the current folder, so it must be the work-tree.
|
|
98
|
+
cwd=git.WORKTREE,
|
|
99
|
+
)
|
|
100
|
+
stdout = stdout.split("\0")
|
|
101
|
+
assert len(stdout) == 4 * len(ignored_files) + 1, (len(ignored_files), len(stdout))
|
|
102
|
+
assert stdout.pop(-1) == ""
|
|
103
|
+
ignored = map(lambda i: stdout[(i*4):(i*4+4)], range(len(stdout) // 4))
|
|
104
|
+
|
|
105
|
+
for ignore_file, ignore_file_line, ignore_pattern, path in ignored:
|
|
106
|
+
ignore_file = None if ignore_file == "" else worktree_path / ignore_file
|
|
107
|
+
ignore_file_line = None if ignore_file_line == "" else strict_int(ignore_file_line)
|
|
108
|
+
assert (ignore_file is None) == (ignore_file_line is None)
|
|
109
|
+
groups = ignore_group_reader.get_groups(ignore_file, ignore_file_line)
|
|
110
|
+
if groups is None:
|
|
111
|
+
group = "<failed to identify matching ignore pattern>"
|
|
112
|
+
elif len(groups) < 1:
|
|
113
|
+
group = "-"
|
|
114
|
+
else:
|
|
115
|
+
assert len(groups) <= 1
|
|
116
|
+
group = groups[0]
|
|
117
|
+
if opts.groups and group not in opts.groups:
|
|
118
|
+
continue
|
|
119
|
+
if group in opts.not_in_groups:
|
|
120
|
+
continue
|
|
121
|
+
ignore_file_fspath = os.fspath(ignore_file) if ignore_file is not None else None
|
|
122
|
+
results.setdefault(group, {}).setdefault(worktree_fspath, {})[path] = [
|
|
123
|
+
ignore_file_fspath, ignore_file_line, ignore_pattern
|
|
124
|
+
]
|
|
125
|
+
set_status_msg(None)
|
|
126
|
+
|
|
127
|
+
if opts.show_lists_only is not None:
|
|
128
|
+
lists_to_show = set(opts.show_lists_only)
|
|
129
|
+
at_least_one_list_shown = False
|
|
130
|
+
def show_list(thelist, *, sort=True):
|
|
131
|
+
if sort:
|
|
132
|
+
thelist = sorted(thelist)
|
|
133
|
+
nonlocal at_least_one_list_shown
|
|
134
|
+
if at_least_one_list_shown:
|
|
135
|
+
sys.stdout.write("\n")
|
|
136
|
+
else:
|
|
137
|
+
at_least_one_list_shown = True
|
|
138
|
+
if opts.format == "json":
|
|
139
|
+
import json
|
|
140
|
+
json.dump(thelist, sys.stdout, indent="\t")
|
|
141
|
+
sys.stdout.write("\n")
|
|
142
|
+
else:
|
|
143
|
+
# TODO If the file name contains unprintable chars (like "Icon\r") this will produce an incorrect list. Consider adding an escape flavor like "python", "shell", "c", etc.
|
|
144
|
+
sys.stdout.write("\n".join(thelist))
|
|
145
|
+
if thelist:
|
|
146
|
+
sys.stdout.write("\n")
|
|
147
|
+
|
|
148
|
+
if "groups" in lists_to_show:
|
|
149
|
+
lists_to_show.remove("groups")
|
|
150
|
+
show_list(list(results.keys()))
|
|
151
|
+
|
|
152
|
+
if "sources" in lists_to_show:
|
|
153
|
+
lists_to_show.remove("sources")
|
|
154
|
+
thelist = set()
|
|
155
|
+
for group in results.values():
|
|
156
|
+
for workdir in group.values():
|
|
157
|
+
for file in workdir.values():
|
|
158
|
+
thelist.add(f"{file[0]}:{file[1]}")
|
|
159
|
+
show_list(map(str, thelist))
|
|
160
|
+
|
|
161
|
+
if "files" in lists_to_show:
|
|
162
|
+
lists_to_show.remove("files")
|
|
163
|
+
thelist = set()
|
|
164
|
+
for group in results.values():
|
|
165
|
+
for workdir_path, workdir in group.items():
|
|
166
|
+
for file in workdir.keys():
|
|
167
|
+
thelist.add(os.path.join(workdir_path, file))
|
|
168
|
+
show_list(thelist)
|
|
169
|
+
|
|
170
|
+
if lists_to_show:
|
|
171
|
+
raise ValueError(f"unsupported lists {lists_to_show}")
|
|
172
|
+
else:
|
|
173
|
+
if opts.format == "json":
|
|
174
|
+
import json
|
|
175
|
+
fo = sys.stdout
|
|
176
|
+
json.dump(results, fo, indent="\t")
|
|
177
|
+
fo.write("\n")
|
|
178
|
+
fo.flush()
|
|
179
|
+
elif opts.format == "table":
|
|
180
|
+
for group, repos in results.items():
|
|
181
|
+
rows = [
|
|
182
|
+
["Work-tree Path", "File Path", "Source", "Pattern"]
|
|
183
|
+
]
|
|
184
|
+
for path, files in repos.items():
|
|
185
|
+
for file, (ignore_path, ignore_line, ignore_pattern) in files.items():
|
|
186
|
+
if file != repr(file)[1:-1]:
|
|
187
|
+
file = repr(file)
|
|
188
|
+
if ignore_path is not None:
|
|
189
|
+
ignore_path_relative = path_relative_to_or_unchanged(path, ignore_path)
|
|
190
|
+
ignore_rule_location = f"{ignore_path_relative}:{ignore_line}"
|
|
191
|
+
else:
|
|
192
|
+
ignore_rule_location = "<unknown>"
|
|
193
|
+
rows.append([
|
|
194
|
+
path, file, ignore_rule_location, ignore_pattern
|
|
195
|
+
])
|
|
196
|
+
draw_table(
|
|
197
|
+
rows,
|
|
198
|
+
title=group,
|
|
199
|
+
has_header=True,
|
|
200
|
+
fo=sys.stdout,
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
raise ValueError(f"unsupported output format {repr(opts.format)}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class IgnoreGroupReader(object):
|
|
207
|
+
def __init__(self):
|
|
208
|
+
self._cache = {}
|
|
209
|
+
|
|
210
|
+
def get_groups(self, path, lineno):
|
|
211
|
+
if path is None or lineno is None:
|
|
212
|
+
return None
|
|
213
|
+
if path not in self._cache:
|
|
214
|
+
self._cache[path] = self._read_ignore_file(path)
|
|
215
|
+
ignore_file_map = self._cache[path]
|
|
216
|
+
assert 0 <= (lineno - 1) <= len(ignore_file_map), (lineno, len(ignore_file_map))
|
|
217
|
+
return ignore_file_map[lineno - 1]
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _read_ignore_file(path):
|
|
221
|
+
result = []
|
|
222
|
+
start_marker = "#{{{"
|
|
223
|
+
end_marker = "#}}}"
|
|
224
|
+
group_name_pattern = re.compile(r"^[a-z0-9_]+$")
|
|
225
|
+
group_stack = []
|
|
226
|
+
with path.open("r") as fo:
|
|
227
|
+
for line_num, line in enumerate(fo, start=1):
|
|
228
|
+
if line.startswith(start_marker):
|
|
229
|
+
# TODO Instead of converting to lowercase, use case-insensitive mapping and report any inconsistencies.
|
|
230
|
+
group = line[len(start_marker):].strip().lower()
|
|
231
|
+
if not group_name_pattern.match(group):
|
|
232
|
+
raise ValueError(f"invalid group name - {repr(group)} in {repr(os.fspath(path))}")
|
|
233
|
+
group_stack.append(group)
|
|
234
|
+
elif line.startswith(end_marker):
|
|
235
|
+
group_stack.pop(-1)
|
|
236
|
+
if len(group_stack) > 1:
|
|
237
|
+
raise ValueError(f"nested groups are not supported: {os.fspath(path)}:{line_num}")
|
|
238
|
+
result.append(list(set(group_stack)))
|
|
239
|
+
return result
|
rgit/cli/registry.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
_command_handlers = {}
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def command(*names, disabled=False):
|
|
5
|
+
def decorator(handler):
|
|
6
|
+
name = names[0]
|
|
7
|
+
if name in _command_handlers:
|
|
8
|
+
raise ValueError("command already registered")
|
|
9
|
+
_command_handlers[name] = (names[1:], handler, disabled)
|
|
10
|
+
return handler
|
|
11
|
+
return decorator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def enumerate_command_handlers():
|
|
15
|
+
for name, (aliases, handler, disabled) in _command_handlers.items():
|
|
16
|
+
yield (name, aliases, handler, disabled)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_command_handler(name):
|
|
20
|
+
if name not in _command_handlers:
|
|
21
|
+
return None
|
|
22
|
+
return _command_handlers[name][1]
|
rgit/cli/scan.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import os, sys, pathlib, collections, functools
|
|
2
|
+
|
|
3
|
+
from ..tools import set_status_msg, add_status_msg
|
|
4
|
+
from .registry import command
|
|
5
|
+
from .. import git
|
|
6
|
+
|
|
7
|
+
# pip install -U PyYAML>=6.0.1,<7.0.0
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@command("scan")
|
|
12
|
+
class Scan(object):
|
|
13
|
+
@classmethod
|
|
14
|
+
def define_arguments(cls, parser):
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"starting_folders",
|
|
17
|
+
metavar="PATH",
|
|
18
|
+
nargs="*",
|
|
19
|
+
help="starting folders from where to conduct the search",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--output", "-o",
|
|
23
|
+
dest="output_path",
|
|
24
|
+
metavar="PATH",
|
|
25
|
+
action="store",
|
|
26
|
+
type=pathlib.Path,
|
|
27
|
+
default=None,
|
|
28
|
+
help="besides outputting found repositories on stdout, also write a YAML document at given path"
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--ignore", "-i",
|
|
32
|
+
dest="scan_folders_ignore",
|
|
33
|
+
metavar="PATH",
|
|
34
|
+
action="append",
|
|
35
|
+
type=pathlib.Path,
|
|
36
|
+
default=[],
|
|
37
|
+
help="do not scan specified folders and their sub-folders",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--show-all",
|
|
41
|
+
dest="show_all",
|
|
42
|
+
action="store_true",
|
|
43
|
+
default=False,
|
|
44
|
+
help="report all found repositories, even those already added to configuration"
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--skip-gitdirs",
|
|
48
|
+
dest="skip_gitdirs",
|
|
49
|
+
action="store_true",
|
|
50
|
+
default=False,
|
|
51
|
+
help="do not traverse into gitdirs of discovered repos"
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--skip-worktrees",
|
|
55
|
+
dest="skip_worktrees",
|
|
56
|
+
action="store_true",
|
|
57
|
+
default=False,
|
|
58
|
+
help="do not traverse into worktrees of discovered repos"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def short_description(cls):
|
|
63
|
+
return "walk the filesystem to discover new repositories"
|
|
64
|
+
|
|
65
|
+
def __init__(self):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
async def execute(self, *, opts, config):
|
|
69
|
+
dirs_to_skip = {
|
|
70
|
+
*(opts.scan_folders_ignore),
|
|
71
|
+
*(config.scan_folders_ignore)
|
|
72
|
+
}
|
|
73
|
+
scan_folders = opts.starting_folders or list(config.scan_folders)
|
|
74
|
+
repositories_to_skip = set(config.repositories) if not opts.show_all else set()
|
|
75
|
+
repositories_found = set()
|
|
76
|
+
counter = 0
|
|
77
|
+
|
|
78
|
+
def should_ignore(gitdir, worktree=None):
|
|
79
|
+
"""Check if a repository should be ignored based on its gitdir or worktree location.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
gitdir: Path to the git directory (.git folder)
|
|
83
|
+
worktree: Optional path to the worktree (None for bare repos or when not yet determined)
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True if the repository should be ignored, False otherwise
|
|
87
|
+
|
|
88
|
+
Note: We check both gitdir and worktree because repos can have custom worktrees
|
|
89
|
+
configured via core.worktree in git config. A repo might be in a non-ignored location
|
|
90
|
+
but have its worktree in an ignored folder, or vice versa.
|
|
91
|
+
"""
|
|
92
|
+
for ignored_folder in dirs_to_skip:
|
|
93
|
+
if gitdir.is_relative_to(ignored_folder):
|
|
94
|
+
return True
|
|
95
|
+
if worktree and worktree.is_relative_to(ignored_folder):
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
for starting_folder in scan_folders:
|
|
100
|
+
for root, dirs, files in os.walk(starting_folder, topdown=True):
|
|
101
|
+
root = pathlib.Path(root)
|
|
102
|
+
dirs.sort(key=lambda x: x.lower())
|
|
103
|
+
|
|
104
|
+
counter += 1
|
|
105
|
+
if counter >= 100:
|
|
106
|
+
if opts.show_progress:
|
|
107
|
+
add_status_msg(".")
|
|
108
|
+
counter = 0
|
|
109
|
+
|
|
110
|
+
repo = None
|
|
111
|
+
|
|
112
|
+
# Note that if traversing into gitdir-s, a regular non-bare repo will be detected
|
|
113
|
+
# twice - once for the worktree and once for the .git folder. Duplicates will be
|
|
114
|
+
# collapsed thought via the `repositories_found` set object.
|
|
115
|
+
|
|
116
|
+
if ".git" in dirs or ".git" in files:
|
|
117
|
+
# If the gitdir is ignored, there is no point in checking the worktree. The
|
|
118
|
+
# worktree could be overridden, but if this folder containing .git is ignored,
|
|
119
|
+
# the whole thing is considered ignored.
|
|
120
|
+
if should_ignore(root / ".git", worktree=root):
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
repo = git.Repo(gitdir=(root / ".git"), worktree=root)
|
|
125
|
+
except ValueError:
|
|
126
|
+
# Skip invalid git directories (e.g., corrupted or incomplete .git folders)
|
|
127
|
+
gitpath = root / ".git"
|
|
128
|
+
if opts.show_progress:
|
|
129
|
+
set_status_msg(None)
|
|
130
|
+
sys.stderr.write(f"WARNING: {gitpath.as_posix()!r} - invalid git repository\n")
|
|
131
|
+
repo = None
|
|
132
|
+
if opts.skip_gitdirs:
|
|
133
|
+
try:
|
|
134
|
+
dirs.remove(".git")
|
|
135
|
+
except ValueError:
|
|
136
|
+
pass
|
|
137
|
+
if opts.skip_worktrees:
|
|
138
|
+
re_add_gitdir = ".git" in dirs
|
|
139
|
+
del dirs[:]
|
|
140
|
+
if re_add_gitdir:
|
|
141
|
+
dirs.append(".git")
|
|
142
|
+
elif "refs" in dirs and "objects" in dirs and "HEAD" in files:
|
|
143
|
+
#TODO:bug: A repo might be set up such that the objects directory is elsewhere,
|
|
144
|
+
# e.g. via GIT_OBJECT_DIRECTORY.
|
|
145
|
+
# See https://github.com/git/git/blob/v2.42.0/setup.c#L345-L355
|
|
146
|
+
|
|
147
|
+
if should_ignore(root):
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
repo = git.Repo(gitdir=root)
|
|
152
|
+
except ValueError:
|
|
153
|
+
# Skip invalid git directories
|
|
154
|
+
gitpath = root
|
|
155
|
+
add_status_msg(f"WARNING: {gitpath.as_posix()} - invalid git repository\n")
|
|
156
|
+
repo = None
|
|
157
|
+
if opts.skip_gitdirs:
|
|
158
|
+
del dirs[:]
|
|
159
|
+
|
|
160
|
+
if repo is None:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if repo in repositories_found:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
if should_ignore(repo.gitdir, repo.worktree):
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
repositories_found.add(repo)
|
|
170
|
+
if opts.show_progress:
|
|
171
|
+
set_status_msg(None)
|
|
172
|
+
if repo.gitdir not in repositories_to_skip:
|
|
173
|
+
await self.report_new_repo(repo)
|
|
174
|
+
|
|
175
|
+
if opts.show_progress:
|
|
176
|
+
set_status_msg(None)
|
|
177
|
+
|
|
178
|
+
if opts.output_path is not None:
|
|
179
|
+
await self.write_output_yaml(repositories_found, opts.output_path)
|
|
180
|
+
|
|
181
|
+
async def write_output_yaml(self, repositories_found, output_path):
|
|
182
|
+
#TODO:cleanup: Refactor this function to extract the logic into methods of the git.Repo class.
|
|
183
|
+
|
|
184
|
+
# We only consider a config to be common is more than half has the same value.
|
|
185
|
+
config_yaml_common_config_threshold = len(repositories_found) // 2
|
|
186
|
+
# The gitdir and worktree paths are relative to user's home folder.
|
|
187
|
+
config_yaml_relative_to = pathlib.Path.home()
|
|
188
|
+
def make_path_relative(path):
|
|
189
|
+
try:
|
|
190
|
+
return path.relative_to(config_yaml_relative_to)
|
|
191
|
+
except ValueError:
|
|
192
|
+
return path
|
|
193
|
+
# This is the resulting object that will be serialized into the output YAML document.
|
|
194
|
+
config_yaml = {
|
|
195
|
+
# Initially this will hold {"git-config-key": {"git-config-value": count}}, but later
|
|
196
|
+
# Will be changed to be {"git-config-key": "git-config-value"} where the value is the
|
|
197
|
+
# one with with the highest count if it is above the threshold.
|
|
198
|
+
"config": (config_yaml_common_config := collections.defaultdict(
|
|
199
|
+
functools.partial(collections.defaultdict, int)
|
|
200
|
+
)),
|
|
201
|
+
"repositories": (config_yaml_repositories := [])
|
|
202
|
+
}
|
|
203
|
+
for repo in sorted(repositories_found, key=lambda r: r.gitdir):
|
|
204
|
+
config_yaml_repositories.append(
|
|
205
|
+
#TODO:yaml: Change this to collections.OrderedDict() and make PyYAML to render it as a regular dict.
|
|
206
|
+
repo_entry := {}
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
repo_entry["gitdir"] = make_path_relative(repo.gitdir).as_posix()
|
|
210
|
+
#TODO:yaml: The gitdir path above and worktree paths below should be added as pathlib.Path objects and rendered by the YAML library in quotes and unbroken.
|
|
211
|
+
if repo.worktree and repo.is_worktree_custom():
|
|
212
|
+
#TODO:bug: Handle bare repos explicitly
|
|
213
|
+
repo_entry["worktrees"] = {
|
|
214
|
+
#TODO:worktrees: Think through the schema of the worktrees sub-object.
|
|
215
|
+
make_path_relative(repo.worktree).as_posix(): None
|
|
216
|
+
}
|
|
217
|
+
#TODO:worktrees: List all the added worktrees along with their custom configurations.
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
repo_entry["remotes"] = {}
|
|
221
|
+
repo_entry["branches"] = {}
|
|
222
|
+
repo_entry["config"] = {}
|
|
223
|
+
|
|
224
|
+
async for c in git.list_config(repo.gitdir):
|
|
225
|
+
key, sep, value = c.partition("\n")
|
|
226
|
+
assert sep == "\n", c
|
|
227
|
+
|
|
228
|
+
# Special Cases
|
|
229
|
+
if key == "core.worktree":
|
|
230
|
+
assert (repo.gitdir / pathlib.Path(value)).samefile(repo.worktree)
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
if key.startswith("remote."):
|
|
234
|
+
section, remote_name, name = git.split_config_key(key)
|
|
235
|
+
assert section == "remote", (key, section, remote_name, name)
|
|
236
|
+
if remote_name:
|
|
237
|
+
if name == "url":
|
|
238
|
+
repo_entry["remotes"][remote_name] = value
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
if key.startswith("branch."):
|
|
242
|
+
section, branch_name, name = git.split_config_key(key)
|
|
243
|
+
assert section == "branch", (key, section, branch_name, name)
|
|
244
|
+
if branch_name:
|
|
245
|
+
repo_entry["branches"].setdefault(branch_name, [None, None])
|
|
246
|
+
if name == "remote":
|
|
247
|
+
repo_entry["branches"][branch_name][0] = value
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
if name == "merge":
|
|
251
|
+
repo_entry["branches"][branch_name][1] = value
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
repo_entry["config"][key] = single_value_or_tuple(repo_entry["config"].get(key), value)
|
|
255
|
+
|
|
256
|
+
remote_fetch_keys = [
|
|
257
|
+
k for k in repo_entry["config"].keys()
|
|
258
|
+
if k.startswith("remote.") and k.endswith(".fetch")
|
|
259
|
+
]
|
|
260
|
+
for key in remote_fetch_keys:
|
|
261
|
+
section, remote_name, name = git.split_config_key(key)
|
|
262
|
+
assert section == "remote" and name == "fetch", (key, section, remote_name, name)
|
|
263
|
+
value = repo_entry["config"][key]
|
|
264
|
+
|
|
265
|
+
# Instead of skipping this, decompose the string to components and compare.
|
|
266
|
+
if value == f"+refs/heads/*:refs/remotes/{remote_name}/*":
|
|
267
|
+
del repo_entry["config"][key]
|
|
268
|
+
|
|
269
|
+
# In case the "remote.<name>.fetch" is either alone or came first
|
|
270
|
+
repo_entry["remotes"].setdefault(remote_name, None)
|
|
271
|
+
|
|
272
|
+
#TODO:yaml: Instead of relying on dict preserving key order, the yaml renderer should sort these keys.
|
|
273
|
+
repo_entry["remotes"] = dict(sorted(repo_entry["remotes"].items(), reverse=True))
|
|
274
|
+
if not len(repo_entry["remotes"]):
|
|
275
|
+
del repo_entry["remotes"]
|
|
276
|
+
|
|
277
|
+
# Updating the common config values as a separate loop to accommodate multi-value keys.
|
|
278
|
+
for key, value in repo_entry["config"].items():
|
|
279
|
+
config_yaml_common_config[key][value] += 1
|
|
280
|
+
#TODO:yaml: The yaml renderer should be able to take tuples as lists.
|
|
281
|
+
repo_entry["config"][key] = str_or_list(value)
|
|
282
|
+
|
|
283
|
+
# Normalize the branches
|
|
284
|
+
branches_flat = []
|
|
285
|
+
for key, value in repo_entry["branches"].items():
|
|
286
|
+
remote, branch = value
|
|
287
|
+
branches_flat.append(f"{key} <- {remote}:{branch}")
|
|
288
|
+
if len(branches_flat):
|
|
289
|
+
repo_entry["branches"] = branches_flat
|
|
290
|
+
else:
|
|
291
|
+
del repo_entry["branches"]
|
|
292
|
+
|
|
293
|
+
# Convert the initial object with counts to have a flat key-value mapping but only if
|
|
294
|
+
# the same value for the key appears in more than the threshold count of repos.
|
|
295
|
+
config_yaml_config = {}
|
|
296
|
+
for k, v in config_yaml["config"].items():
|
|
297
|
+
# Get the value which has the biggest count
|
|
298
|
+
v, count = sorted(v.items(), key=lambda x: x[1], reverse=True)[0]
|
|
299
|
+
if count < config_yaml_common_config_threshold:
|
|
300
|
+
# Only continue if count of the most often occurring value is at or above the threshold
|
|
301
|
+
continue
|
|
302
|
+
config_yaml_config[k] = str_or_list(v)
|
|
303
|
+
config_yaml["config"] = config_yaml_config
|
|
304
|
+
|
|
305
|
+
for repo_entry in config_yaml["repositories"]:
|
|
306
|
+
for common_config_key, common_config_value in config_yaml["config"].items():
|
|
307
|
+
if common_config_key not in repo_entry["config"]:
|
|
308
|
+
repo_entry["config"][common_config_key] = None
|
|
309
|
+
elif repo_entry["config"][common_config_key] == common_config_value:
|
|
310
|
+
del repo_entry["config"][common_config_key]
|
|
311
|
+
if not len(repo_entry["config"]):
|
|
312
|
+
del repo_entry["config"]
|
|
313
|
+
|
|
314
|
+
with open(output_path, "w", encoding="UTF-8") as fo:
|
|
315
|
+
yaml.dump(config_yaml, default_flow_style=False, sort_keys=False, stream=fo)
|
|
316
|
+
|
|
317
|
+
async def report_new_repo(self, repo):
|
|
318
|
+
gitdir = repo.gitdir
|
|
319
|
+
try:
|
|
320
|
+
gitdir = gitdir.relative_to(pathlib.Path.home())
|
|
321
|
+
except ValueError:
|
|
322
|
+
pass
|
|
323
|
+
sys.stdout.write(gitdir.as_posix())
|
|
324
|
+
if repo.is_worktree_custom():
|
|
325
|
+
sys.stdout.write("\t-> ")
|
|
326
|
+
sys.stdout.write(repo.worktree.as_posix() if repo.worktree else "(bare)")
|
|
327
|
+
sys.stdout.write("\n")
|
|
328
|
+
sys.stdout.flush()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def single_value_or_tuple(previous_value, new_value):
|
|
332
|
+
if previous_value is None:
|
|
333
|
+
return new_value
|
|
334
|
+
elif isinstance(previous_value, str):
|
|
335
|
+
return (previous_value, new_value)
|
|
336
|
+
else:
|
|
337
|
+
return (*previous_value, new_value)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def str_or_list(value):
|
|
341
|
+
return value if isinstance(value, str) else list(value)
|