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 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)