gitdirector 0.1.5__tar.gz

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.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.3
2
+ Name: gitdirector
3
+ Version: 0.1.5
4
+ Summary: A Python CLI tool for managing and synchronizing multiple git repositories with ease
5
+ Keywords: git,repository,manager,cli,synchronization,batch
6
+ Author: Anito Anto
7
+ Author-email: Anito Anto <49053859+anitoanto@users.noreply.github.com>
8
+ License: MIT
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Version Control :: Git
19
+ Requires-Dist: pyyaml>=6.0
20
+ Requires-Dist: click>=8.1.0
21
+ Requires-Dist: rich>=12.0
22
+ Requires-Dist: pytest>=7.0 ; extra == 'dev'
23
+ Requires-Dist: pytest-cov>=4.0 ; extra == 'dev'
24
+ Requires-Dist: black>=23.0 ; extra == 'dev'
25
+ Requires-Dist: ruff>=0.1.0 ; extra == 'dev'
26
+ Requires-Dist: mypy>=1.0 ; extra == 'dev'
27
+ Requires-Dist: build>=1.0 ; extra == 'dev'
28
+ Requires-Dist: twine>=4.0 ; extra == 'dev'
29
+ Maintainer: Anito Anto
30
+ Maintainer-email: Anito Anto <49053859+anitoanto@users.noreply.github.com>
31
+ Requires-Python: >=3.9
32
+ Project-URL: Homepage, https://github.com/anitoanto/gitdirector
33
+ Project-URL: Repository, https://github.com/anitoanto/gitdirector.git
34
+ Project-URL: Issues, https://github.com/anitoanto/gitdirector/issues
35
+ Project-URL: Documentation, https://github.com/anitoanto/gitdirector/blob/main/README.md
36
+ Provides-Extra: dev
37
+ Description-Content-Type: text/markdown
38
+
39
+ # GitDirector
40
+
41
+ A Python CLI tool for managing and synchronizing multiple git repositories.
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install gitdirector
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ```
52
+ gitdirector add PATH [--discover] Add a repository or discover all under a path
53
+ gitdirector remove PATH [--discover] Remove a repository or all under a path
54
+ gitdirector list List all tracked repositories with live status
55
+ gitdirector status Show dirty repositories with staged/unstaged files
56
+ gitdirector pull Pull latest changes for all tracked repositories
57
+ gitdirector help Show help
58
+ ```
59
+
60
+ ### add
61
+
62
+ ```bash
63
+ gitdirector add /path/to/repo
64
+ gitdirector add /path/to/folder --discover # recursively find and add all repos
65
+ ```
66
+
67
+ ### remove
68
+
69
+ ```bash
70
+ gitdirector remove /path/to/repo
71
+ gitdirector remove /path/to/folder --discover
72
+ ```
73
+
74
+ ### list
75
+
76
+ Displays a live table of all tracked repositories with:
77
+
78
+ - Sync state: `up to date`, `ahead`, `behind`, `diverged`, or `unknown`
79
+ - Current branch
80
+ - Staged/unstaged changes
81
+ - Last commit (relative time)
82
+ - Tracked file size
83
+ - Path
84
+
85
+ Checks run concurrently (default: 10 workers).
86
+
87
+ ### status
88
+
89
+ Shows repositories with uncommitted changes (staged and/or unstaged files). Prints a summary of total, clean, and changed repo counts.
90
+
91
+ ### pull
92
+
93
+ Pulls all tracked repositories concurrently using fast-forward only (`git pull --ff-only`). Reports success or failure per repository.
94
+
95
+ ## Configuration
96
+
97
+ Config is stored at `~/.gitdirector/config.yaml`.
98
+
99
+ ```yaml
100
+ repositories:
101
+ - /path/to/repo1
102
+ - /path/to/repo2
103
+ max_workers: 10 # optional, default 10
104
+ ```
105
+
106
+ ## Requirements
107
+
108
+ - Python 3.9+
109
+ - Git
110
+
111
+ ## License
112
+
113
+ MIT
@@ -0,0 +1,75 @@
1
+ # GitDirector
2
+
3
+ A Python CLI tool for managing and synchronizing multiple git repositories.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install gitdirector
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```
14
+ gitdirector add PATH [--discover] Add a repository or discover all under a path
15
+ gitdirector remove PATH [--discover] Remove a repository or all under a path
16
+ gitdirector list List all tracked repositories with live status
17
+ gitdirector status Show dirty repositories with staged/unstaged files
18
+ gitdirector pull Pull latest changes for all tracked repositories
19
+ gitdirector help Show help
20
+ ```
21
+
22
+ ### add
23
+
24
+ ```bash
25
+ gitdirector add /path/to/repo
26
+ gitdirector add /path/to/folder --discover # recursively find and add all repos
27
+ ```
28
+
29
+ ### remove
30
+
31
+ ```bash
32
+ gitdirector remove /path/to/repo
33
+ gitdirector remove /path/to/folder --discover
34
+ ```
35
+
36
+ ### list
37
+
38
+ Displays a live table of all tracked repositories with:
39
+
40
+ - Sync state: `up to date`, `ahead`, `behind`, `diverged`, or `unknown`
41
+ - Current branch
42
+ - Staged/unstaged changes
43
+ - Last commit (relative time)
44
+ - Tracked file size
45
+ - Path
46
+
47
+ Checks run concurrently (default: 10 workers).
48
+
49
+ ### status
50
+
51
+ Shows repositories with uncommitted changes (staged and/or unstaged files). Prints a summary of total, clean, and changed repo counts.
52
+
53
+ ### pull
54
+
55
+ Pulls all tracked repositories concurrently using fast-forward only (`git pull --ff-only`). Reports success or failure per repository.
56
+
57
+ ## Configuration
58
+
59
+ Config is stored at `~/.gitdirector/config.yaml`.
60
+
61
+ ```yaml
62
+ repositories:
63
+ - /path/to/repo1
64
+ - /path/to/repo2
65
+ max_workers: 10 # optional, default 10
66
+ ```
67
+
68
+ ## Requirements
69
+
70
+ - Python 3.9+
71
+ - Git
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,92 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.11.2,<0.12.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "gitdirector"
7
+ version = "0.1.5"
8
+ description = "A Python CLI tool for managing and synchronizing multiple git repositories with ease"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [
12
+ { name = "Anito Anto", email = "49053859+anitoanto@users.noreply.github.com" }
13
+ ]
14
+ maintainers = [
15
+ { name = "Anito Anto", email = "49053859+anitoanto@users.noreply.github.com" }
16
+ ]
17
+ requires-python = ">=3.9"
18
+ keywords = ["git", "repository", "manager", "cli", "synchronization", "batch"]
19
+ classifiers = [
20
+ "Environment :: Console",
21
+ "Intended Audience :: Developers",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.9",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Topic :: Software Development :: Version Control :: Git",
30
+ ]
31
+ dependencies = [
32
+ "pyyaml>=6.0",
33
+ "click>=8.1.0",
34
+ "rich>=12.0",
35
+ ]
36
+
37
+ [project.optional-dependencies]
38
+ dev = [
39
+ "pytest>=7.0",
40
+ "pytest-cov>=4.0",
41
+ "black>=23.0",
42
+ "ruff>=0.1.0",
43
+ "mypy>=1.0",
44
+ "build>=1.0",
45
+ "twine>=4.0",
46
+ ]
47
+
48
+ [project.urls]
49
+ Homepage = "https://github.com/anitoanto/gitdirector"
50
+ Repository = "https://github.com/anitoanto/gitdirector.git"
51
+ Issues = "https://github.com/anitoanto/gitdirector/issues"
52
+ Documentation = "https://github.com/anitoanto/gitdirector/blob/main/README.md"
53
+
54
+ [project.scripts]
55
+ gitdirector = "gitdirector.cli:main"
56
+
57
+ [tool.uv]
58
+ managed = true
59
+
60
+ [dependency-groups]
61
+ dev = [
62
+ "pytest>=7.0",
63
+ "pytest-cov>=4.0",
64
+ "pytest-mock>=3.0",
65
+ "black>=23.0",
66
+ "ruff>=0.1.0",
67
+ "mypy>=1.0",
68
+ ]
69
+
70
+ [tool.black]
71
+ line-length = 100
72
+ target-version = ["py39", "py310", "py311", "py312"]
73
+
74
+ [tool.ruff]
75
+ line-length = 100
76
+ target-version = "py39"
77
+
78
+ [tool.ruff.lint]
79
+ select = ["E", "F", "W", "I"]
80
+
81
+ [tool.ruff.lint.per-file-ignores]
82
+ "__init__.py" = ["F401"]
83
+
84
+ [tool.mypy]
85
+ python_version = "3.12"
86
+ warn_return_any = true
87
+ warn_unused_configs = true
88
+ disallow_untyped_defs = false
89
+
90
+ [tool.pytest.ini_options]
91
+ testpaths = ["tests"]
92
+ addopts = "--cov=src/gitdirector --cov-report=term-missing"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,405 @@
1
+ from concurrent.futures import ThreadPoolExecutor, as_completed
2
+ from importlib.metadata import version
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import click
7
+ from rich import box
8
+ from rich.console import Console, Group
9
+ from rich.live import Live
10
+ from rich.spinner import Spinner
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from .manager import RepositoryManager
15
+ from .repo import Repository, RepositoryInfo, RepoStatus
16
+
17
+ __version__ = version("gitdirector")
18
+
19
+ console = Console(highlight=False)
20
+
21
+ _STATUS_COLOR = {
22
+ RepoStatus.UP_TO_DATE: "green",
23
+ RepoStatus.BEHIND: "yellow",
24
+ RepoStatus.AHEAD: "cyan",
25
+ RepoStatus.DIVERGED: "red",
26
+ RepoStatus.UNKNOWN: "bright_black",
27
+ }
28
+
29
+ _STATUS_LABEL = {
30
+ RepoStatus.UP_TO_DATE: "up to date",
31
+ RepoStatus.BEHIND: "behind",
32
+ RepoStatus.AHEAD: "ahead",
33
+ RepoStatus.DIVERGED: "diverged",
34
+ RepoStatus.UNKNOWN: "unknown",
35
+ }
36
+
37
+
38
+ def _status_text(status: RepoStatus) -> Text:
39
+ color = _STATUS_COLOR.get(status, "white")
40
+ label = _STATUS_LABEL.get(status, status.value)
41
+ return Text(label, style=color)
42
+
43
+
44
+ def _format_size(size: Optional[int]) -> Text:
45
+ if size is None:
46
+ return Text("—", style="bright_black")
47
+ for unit, threshold in (("GB", 1 << 30), ("MB", 1 << 20), ("KB", 1 << 10)):
48
+ if size >= threshold:
49
+ return Text(f"{size / threshold:.1f} {unit}", style="dim")
50
+ return Text(f"{size} B", style="dim")
51
+
52
+
53
+ def _changes_text(staged: bool, unstaged: bool) -> Text:
54
+ if staged and unstaged:
55
+ return Text("staged+unstaged", style="yellow")
56
+ elif staged:
57
+ return Text("staged", style="cyan")
58
+ elif unstaged:
59
+ return Text("unstaged", style="yellow")
60
+ return Text("—", style="bright_black")
61
+
62
+
63
+ def _path_text(path: str) -> Text:
64
+ col_width = max(10, console.width * 2 // 9 - 6)
65
+ if len(path) > col_width:
66
+ path = "\u2026" + path[-(col_width - 1) :]
67
+ return Text(path, justify="right")
68
+
69
+
70
+ def _build_repo_table(results: list) -> Table:
71
+ table = _repo_table()
72
+ for info in sorted(results, key=lambda r: r.name.lower()):
73
+ table.add_row(
74
+ info.name,
75
+ _status_text(info.status),
76
+ info.branch or "—",
77
+ _changes_text(info.staged, info.unstaged),
78
+ info.last_updated or "—",
79
+ _format_size(info.size),
80
+ _path_text(str(info.path)),
81
+ )
82
+ return table
83
+
84
+
85
+ def _build_pull_table(results: list) -> tuple[Table, int, int]:
86
+ table = _pull_table()
87
+ success_count = 0
88
+ failed_count = 0
89
+ for name, ok, msg in sorted(results, key=lambda r: r[0].lower()):
90
+ if ok:
91
+ table.add_row(name, Text(msg, style="green"))
92
+ success_count += 1
93
+ else:
94
+ table.add_row(name, Text(msg, style="red"))
95
+ failed_count += 1
96
+ return table, success_count, failed_count
97
+
98
+
99
+ def _repo_table() -> Table:
100
+ table = Table(
101
+ box=box.SIMPLE_HEAD,
102
+ expand=True,
103
+ show_header=True,
104
+ header_style="bold",
105
+ show_edge=False,
106
+ padding=(0, 1),
107
+ )
108
+ table.add_column("REPOSITORY", ratio=2)
109
+ table.add_column("SYNC", no_wrap=True, ratio=1)
110
+ table.add_column("BRANCH", style="dim", no_wrap=True, ratio=1)
111
+ table.add_column("CHANGES", no_wrap=True, ratio=1)
112
+ table.add_column("LAST COMMIT", style="dim", no_wrap=True, ratio=1)
113
+ table.add_column("SIZE", style="dim", no_wrap=True, ratio=1, justify="right")
114
+ table.add_column("PATH", style="dim", ratio=2, no_wrap=True, justify="right")
115
+ return table
116
+
117
+
118
+ def show_help():
119
+ console.print()
120
+ console.print(
121
+ f" [bold white]GITDIRECTOR[/bold white] "
122
+ f"[dim]v{__version__} - Manage multiple git repositories[/dim]\n"
123
+ )
124
+
125
+ console.print(" [dim]Commands[/dim]\n")
126
+
127
+ cmd_table = Table(
128
+ box=None,
129
+ show_header=False,
130
+ show_edge=False,
131
+ padding=(0, 2),
132
+ expand=False,
133
+ )
134
+ cmd_table.add_column("cmd", style="white", no_wrap=True)
135
+ cmd_table.add_column("desc", style="dim")
136
+
137
+ for cmd, desc in [
138
+ ("add PATH [--discover]", "Add a repository or discover all repos under a path"),
139
+ ("remove PATH [--discover]", "Remove a repository or all repos under a path"),
140
+ ("list", "List all tracked repositories"),
141
+ ("status", "Show status summary and per-repo details"),
142
+ ("pull", "Pull latest changes for all tracked repositories"),
143
+ ("help", "Show this help message"),
144
+ ]:
145
+ cmd_table.add_row(cmd, desc)
146
+
147
+ console.print(cmd_table)
148
+
149
+ console.print()
150
+
151
+
152
+ class _HelpGroup(click.Group):
153
+ def format_help(self, ctx, formatter):
154
+ show_help()
155
+
156
+
157
+ @click.group(cls=_HelpGroup, invoke_without_command=True)
158
+ @click.pass_context
159
+ def cli(ctx):
160
+ if ctx.invoked_subcommand is None:
161
+ show_help()
162
+
163
+
164
+ @cli.command()
165
+ @click.argument("path", type=click.Path(exists=False))
166
+ @click.option("--discover", is_flag=True, help="Recursively discover repositories")
167
+ def add(path: str, discover: bool):
168
+ manager = RepositoryManager()
169
+ success, message, repos, skipped = manager.add_repository(Path(path), discover=discover)
170
+
171
+ console.print()
172
+ if success:
173
+ if discover:
174
+ console.print(f" {message}")
175
+ for repo_path in repos:
176
+ console.print(f" [green]+[/green] {repo_path}")
177
+ for repo_path in skipped:
178
+ console.print(
179
+ f" [dim yellow]\\[skipped][/dim yellow] "
180
+ f"[bright_black]{repo_path}[/bright_black]"
181
+ )
182
+ else:
183
+ console.print(f" [green]+[/green] {message}")
184
+ else:
185
+ console.print(f" [red]{message}[/red]")
186
+ console.print()
187
+ raise SystemExit(1)
188
+ console.print()
189
+
190
+
191
+ @cli.command()
192
+ @click.argument("path", type=click.Path(exists=False))
193
+ @click.option("--discover", is_flag=True, help="Recursively discover repositories to remove")
194
+ def remove(path: str, discover: bool):
195
+ manager = RepositoryManager()
196
+ success, message, repos = manager.remove_repository(Path(path), discover=discover)
197
+
198
+ console.print()
199
+ if success:
200
+ console.print(f" {message}")
201
+ if repos:
202
+ for repo_path in repos:
203
+ console.print(f" [yellow]-[/yellow] {repo_path}")
204
+ else:
205
+ console.print(f" [red]{message}[/red]")
206
+ console.print()
207
+ raise SystemExit(1)
208
+ console.print()
209
+
210
+
211
+ @cli.command(name="list")
212
+ def list_repos():
213
+ manager = RepositoryManager()
214
+ paths = sorted(manager.config.repositories, key=lambda p: p.name.lower())
215
+
216
+ console.print()
217
+ if not paths:
218
+ console.print(" [dim]No repositories tracked[/dim]\n")
219
+ return
220
+
221
+ with Live(console=console, refresh_per_second=12, transient=False) as live:
222
+ with ThreadPoolExecutor(max_workers=manager.config.max_workers) as executor:
223
+ futures = {executor.submit(manager.get_repository_status, path): path for path in paths}
224
+ remaining = len(futures)
225
+ live.update(
226
+ Group(
227
+ _repo_table(),
228
+ Spinner("dots", text=f" [dim]checking {remaining} repositories...[/dim]"),
229
+ )
230
+ )
231
+ results = []
232
+ for future in as_completed(futures):
233
+ remaining -= 1
234
+ results.append(future.result())
235
+ table = _build_repo_table(results)
236
+ if remaining > 0:
237
+ live.update(
238
+ Group(table, Spinner("dots", text=f" [dim]{remaining} remaining...[/dim]"))
239
+ )
240
+ else:
241
+ live.update(table)
242
+
243
+ console.print()
244
+ total = len(paths)
245
+ noun = "repository" if total == 1 else "repositories"
246
+ console.print(f" [green]{total} {noun}[/green]\n")
247
+
248
+
249
+ def _build_dirty_display(results: list[RepositoryInfo]) -> Text:
250
+ dirty_repos = sorted(
251
+ [r for r in results if r.staged or r.unstaged], key=lambda r: r.name.lower()
252
+ )
253
+ output = Text()
254
+ for repo in dirty_repos:
255
+ output.append(f" {repo.name}", style="bold white")
256
+ output.append(f" {repo.branch or '—'}\n", style="dim")
257
+ if repo.staged_files:
258
+ for f in repo.staged_files:
259
+ output.append(" ")
260
+ output.append("staged:", style="cyan")
261
+ output.append(f" {f}\n")
262
+ if repo.unstaged_files:
263
+ for f in repo.unstaged_files:
264
+ output.append(" ")
265
+ output.append("unstaged:", style="yellow")
266
+ output.append(f" {f}\n")
267
+ output.append("\n")
268
+ return output
269
+
270
+
271
+ @cli.command()
272
+ def status():
273
+ manager = RepositoryManager()
274
+ paths = sorted(manager.config.repositories, key=lambda p: p.name.lower())
275
+
276
+ console.print()
277
+ if not paths:
278
+ console.print(" [dim]No repositories tracked[/dim]\n")
279
+ return
280
+
281
+ results = []
282
+ with Live(console=console, refresh_per_second=12, transient=False) as live:
283
+ with ThreadPoolExecutor(max_workers=manager.config.max_workers) as executor:
284
+ futures = {executor.submit(manager.get_repository_status, path): path for path in paths}
285
+ remaining = len(futures)
286
+ live.update(Spinner("dots", text=f" [dim]checking {remaining} repositories...[/dim]"))
287
+ for future in as_completed(futures):
288
+ remaining -= 1
289
+ results.append(future.result())
290
+ display = _build_dirty_display(results)
291
+ if remaining > 0:
292
+ live.update(
293
+ Group(
294
+ display, Spinner("dots", text=f" [dim]{remaining} remaining...[/dim]")
295
+ )
296
+ )
297
+ else:
298
+ live.update(display)
299
+
300
+ total = len(results)
301
+ dirty = sum(1 for r in results if r.staged or r.unstaged)
302
+ clean = total - dirty
303
+
304
+ if not dirty:
305
+ console.print(" [dim]All repositories are clean[/dim]")
306
+ console.print()
307
+
308
+ summary = Text(" ")
309
+ summary.append(str(total), style="bold white")
310
+ summary.append(" repositories", style="dim")
311
+ summary.append(" ")
312
+ summary.append(f"{clean} clean", style="green")
313
+ if dirty:
314
+ summary.append(f" {dirty} changed", style="yellow")
315
+
316
+ console.print(summary)
317
+ console.print()
318
+
319
+
320
+ def _pull_table() -> Table:
321
+ table = Table(
322
+ box=box.SIMPLE_HEAD,
323
+ expand=True,
324
+ show_header=True,
325
+ header_style="bold",
326
+ show_edge=False,
327
+ padding=(0, 1),
328
+ )
329
+ table.add_column("REPOSITORY", ratio=3)
330
+ table.add_column("RESULT", ratio=6)
331
+ return table
332
+
333
+
334
+ def _pull_one(path: Path) -> tuple[str, bool, str]:
335
+ name = path.name
336
+ if not path.exists() or not (path / ".git").is_dir():
337
+ return name, False, "path not found"
338
+ try:
339
+ repo = Repository(path)
340
+ ok, msg = repo.pull()
341
+ return name, ok, msg
342
+ except Exception as e:
343
+ return name, False, str(e)
344
+
345
+
346
+ @cli.command()
347
+ def pull():
348
+ manager = RepositoryManager()
349
+ paths = sorted(manager.config.repositories, key=lambda p: p.name.lower())
350
+
351
+ console.print()
352
+ if not paths:
353
+ console.print(" [dim]No repositories tracked[/dim]\n")
354
+ return
355
+
356
+ failed_count = 0
357
+ success_count = 0
358
+
359
+ with Live(console=console, refresh_per_second=12, transient=False) as live:
360
+ with ThreadPoolExecutor(max_workers=manager.config.max_workers) as executor:
361
+ futures = {executor.submit(_pull_one, path): path for path in paths}
362
+ remaining = len(futures)
363
+ live.update(
364
+ Group(
365
+ _pull_table(),
366
+ Spinner("dots", text=f" [dim]pulling {remaining} repositories...[/dim]"),
367
+ )
368
+ )
369
+ results = []
370
+ for future in as_completed(futures):
371
+ remaining -= 1
372
+ results.append(future.result())
373
+ table, success_count, failed_count = _build_pull_table(results)
374
+ if remaining > 0:
375
+ live.update(
376
+ Group(table, Spinner("dots", text=f" [dim]{remaining} remaining...[/dim]"))
377
+ )
378
+ else:
379
+ live.update(table)
380
+
381
+ console.print()
382
+ if failed_count:
383
+ noun = "repository" if failed_count == 1 else "repositories"
384
+ console.print(f" [red]{failed_count} {noun} failed[/red]\n")
385
+ raise SystemExit(1)
386
+ else:
387
+ noun = "repository" if success_count == 1 else "repositories"
388
+ console.print(f" [green]{success_count} {noun}[/green]\n")
389
+
390
+
391
+ @cli.command()
392
+ def help():
393
+ show_help()
394
+
395
+
396
+ def main():
397
+ try:
398
+ cli()
399
+ except Exception as e:
400
+ console.print(f"\n [red]Error:[/red] {str(e)}\n")
401
+ raise SystemExit(1)
402
+
403
+
404
+ if __name__ == "__main__":
405
+ main()
@@ -0,0 +1,54 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+
6
+ class Config:
7
+ def __init__(self):
8
+ self.config_dir = Path.home() / ".gitdirector"
9
+ self.config_file = self.config_dir / "config.yaml"
10
+ self._ensure_config_dir()
11
+ self._load()
12
+
13
+ def _ensure_config_dir(self) -> None:
14
+ self.config_dir.mkdir(exist_ok=True)
15
+
16
+ DEFAULT_MAX_WORKERS = 10
17
+
18
+ def _load(self) -> None:
19
+ if self.config_file.exists():
20
+ with open(self.config_file, "r") as f:
21
+ data = yaml.safe_load(f) or {}
22
+ self.repositories = [Path(p) for p in data.get("repositories", [])]
23
+ self.max_workers = int(data.get("max_workers", self.DEFAULT_MAX_WORKERS))
24
+ else:
25
+ self.repositories = []
26
+ self.max_workers = self.DEFAULT_MAX_WORKERS
27
+
28
+ def save(self) -> None:
29
+ data: dict = {"repositories": [str(p) for p in self.repositories]}
30
+ if self.max_workers != self.DEFAULT_MAX_WORKERS:
31
+ data["max_workers"] = self.max_workers
32
+ with open(self.config_file, "w") as f:
33
+ yaml.dump(data, f, default_flow_style=False)
34
+
35
+ def add_repository(self, path: Path) -> bool:
36
+ if path not in self.repositories:
37
+ self.repositories.append(path)
38
+ self.save()
39
+ return True
40
+ return False
41
+
42
+ def remove_repository(self, path: Path) -> bool:
43
+ if path in self.repositories:
44
+ self.repositories.remove(path)
45
+ self.save()
46
+ return True
47
+ return False
48
+
49
+ def has_repository(self, path: Path) -> bool:
50
+ return path in self.repositories
51
+
52
+ def clear(self) -> None:
53
+ self.repositories = []
54
+ self.save()
@@ -0,0 +1,156 @@
1
+ from pathlib import Path
2
+ from typing import List, Tuple
3
+
4
+ from .config import Config
5
+ from .repo import Repository, RepositoryInfo, RepoStatus
6
+
7
+
8
+ class RepositoryManager:
9
+ def __init__(self):
10
+ self.config = Config()
11
+
12
+ def add_repository(
13
+ self, path: Path, discover: bool = False
14
+ ) -> Tuple[bool, str, List[Path], List[Path]]:
15
+ if discover:
16
+ return self._discover_and_add(path)
17
+ else:
18
+ return self._add_single(path)
19
+
20
+ def _add_single(self, path: Path) -> Tuple[bool, str, List[Path], List[Path]]:
21
+ path = path.resolve()
22
+
23
+ if not path.exists():
24
+ return False, f"Path does not exist: {path}", [], []
25
+
26
+ if not path.is_dir():
27
+ return False, f"Path is not a directory: {path}", [], []
28
+
29
+ if not (path / ".git").is_dir():
30
+ return False, f"Not a git repository: {path}", [], []
31
+
32
+ if self.config.has_repository(path):
33
+ return False, f"Repository already tracked: {path}", [], []
34
+
35
+ try:
36
+ self.config.add_repository(path)
37
+ return True, f"Added repository: {path}", [path], []
38
+ except Exception as e:
39
+ return False, f"Error adding repository: {str(e)}", [], []
40
+
41
+ def _discover_and_add(self, root: Path) -> Tuple[bool, str, List[Path], List[Path]]:
42
+ root = root.resolve()
43
+
44
+ if not root.exists():
45
+ return False, f"Path does not exist: {root}", [], []
46
+
47
+ if not root.is_dir():
48
+ return False, f"Path is not a directory: {root}", [], []
49
+
50
+ repos = []
51
+ skipped = []
52
+
53
+ for item in root.rglob(".git"):
54
+ repo_path = item.parent
55
+ if self.config.has_repository(repo_path):
56
+ skipped.append(repo_path)
57
+ continue
58
+
59
+ try:
60
+ self.config.add_repository(repo_path)
61
+ repos.append(repo_path)
62
+ except Exception as _:
63
+ skipped.append(repo_path)
64
+
65
+ if not repos:
66
+ msg = "No new repositories found" if skipped else "No git repositories found"
67
+ return False, msg, [], skipped
68
+
69
+ msg = (
70
+ f"Added {len(repos)} repository"
71
+ if len(repos) == 1
72
+ else f"Added {len(repos)} repositories"
73
+ )
74
+
75
+ return True, msg, repos, skipped
76
+
77
+ def remove_repository(self, path: Path, discover: bool = False) -> Tuple[bool, str, List[Path]]:
78
+ if discover:
79
+ return self._discover_and_remove(path)
80
+ else:
81
+ return self._remove_single(path)
82
+
83
+ def _remove_single(self, path: Path) -> Tuple[bool, str, List[Path]]:
84
+ path = path.resolve()
85
+
86
+ if not self.config.has_repository(path):
87
+ return False, f"Repository not tracked: {path}", []
88
+
89
+ try:
90
+ self.config.remove_repository(path)
91
+ return True, f"Removed repository: {path}", [path]
92
+ except Exception as e:
93
+ return False, f"Error removing repository: {str(e)}", []
94
+
95
+ def _discover_and_remove(self, root: Path) -> Tuple[bool, str, List[Path]]:
96
+ root = root.resolve()
97
+
98
+ repos_to_remove = [r for r in self.config.repositories if r.is_relative_to(root)]
99
+
100
+ if not repos_to_remove:
101
+ return False, f"No tracked repositories found under: {root}", []
102
+
103
+ try:
104
+ for repo_path in repos_to_remove:
105
+ self.config.remove_repository(repo_path)
106
+
107
+ msg = (
108
+ f"Removed {len(repos_to_remove)} repository"
109
+ if len(repos_to_remove) == 1
110
+ else f"Removed {len(repos_to_remove)} repositories"
111
+ )
112
+ return True, msg, repos_to_remove
113
+ except Exception as e:
114
+ return False, f"Error removing repositories: {str(e)}", []
115
+
116
+ def get_repository_status(self, path: Path) -> RepositoryInfo:
117
+ if path.exists() and (path / ".git").is_dir():
118
+ try:
119
+ repo = Repository(path)
120
+ return repo.get_status()
121
+ except Exception as e:
122
+ return RepositoryInfo(path, path.name, RepoStatus.UNKNOWN, None, str(e))
123
+ return RepositoryInfo(
124
+ path,
125
+ path.name,
126
+ RepoStatus.UNKNOWN,
127
+ None,
128
+ "Repository path not found or invalid",
129
+ )
130
+
131
+ def list_repositories(self) -> List[RepositoryInfo]:
132
+ return [self.get_repository_status(path) for path in self.config.repositories]
133
+
134
+ def pull_all(self) -> Tuple[List[str], List[str]]:
135
+ success = []
136
+ failed = []
137
+
138
+ for path in self.config.repositories:
139
+ if not path.exists() or not (path / ".git").is_dir():
140
+ failed.append(f"{path.name}: Path not found or invalid")
141
+ continue
142
+
143
+ try:
144
+ repo = Repository(path)
145
+ ok, msg = repo.pull()
146
+ if ok:
147
+ success.append(f"{path.name}: {msg}")
148
+ else:
149
+ failed.append(f"{path.name}: {msg}")
150
+ except Exception as e:
151
+ failed.append(f"{path.name}: {str(e)}")
152
+
153
+ return success, failed
154
+
155
+ def get_repository_count(self) -> int:
156
+ return len(self.config.repositories)
@@ -0,0 +1,153 @@
1
+ import subprocess
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ class RepoStatus(Enum):
9
+ UP_TO_DATE = "up-to-date"
10
+ AHEAD = "ahead"
11
+ BEHIND = "behind"
12
+ DIVERGED = "diverged"
13
+ UNKNOWN = "unknown"
14
+
15
+
16
+ @dataclass
17
+ class RepositoryInfo:
18
+ path: Path
19
+ name: str
20
+ status: RepoStatus
21
+ branch: Optional[str] = None
22
+ message: str = ""
23
+ staged: bool = False
24
+ unstaged: bool = False
25
+ staged_files: Optional[list[str]] = None
26
+ unstaged_files: Optional[list[str]] = None
27
+ last_updated: Optional[str] = None
28
+ size: Optional[int] = None
29
+
30
+ def __repr__(self) -> str:
31
+ return f"{self.name:<30} {self.status.value:<12} {self.branch or 'N/A':<15}"
32
+
33
+
34
+ class Repository:
35
+ def __init__(self, path: Path):
36
+ if not self._is_git_repo(path):
37
+ raise ValueError(f"Not a git repository: {path}")
38
+ self.path = path
39
+ self.name = path.name
40
+
41
+ @staticmethod
42
+ def _is_git_repo(path: Path) -> bool:
43
+ return (path / ".git").is_dir()
44
+
45
+ def _run_git(self, *args: str, _strip: bool = True) -> tuple[int, str, str]:
46
+ try:
47
+ result = subprocess.run(
48
+ ["git", "-C", str(self.path)] + list(args),
49
+ capture_output=True,
50
+ text=True,
51
+ timeout=10,
52
+ )
53
+ stdout = result.stdout.strip() if _strip else result.stdout
54
+ return result.returncode, stdout, result.stderr.strip()
55
+ except subprocess.TimeoutExpired:
56
+ return 1, "", "git command timed out"
57
+ except FileNotFoundError:
58
+ return 1, "", "git not found"
59
+
60
+ def get_current_branch(self) -> Optional[str]:
61
+ code, out, _ = self._run_git("rev-parse", "--abbrev-ref", "HEAD")
62
+ return out if code == 0 else None
63
+
64
+ def get_last_commit_date(self) -> Optional[str]:
65
+ code, out, _ = self._run_git("log", "-1", "--format=%cd", "--date=relative")
66
+ return out if code == 0 and out else None
67
+
68
+ def get_tracked_size(self) -> Optional[int]:
69
+ """Return total byte size of all tracked files (respects .gitignore)."""
70
+ code, out, _ = self._run_git("ls-files", "-z", _strip=False)
71
+ if code != 0 or not out:
72
+ return None
73
+ total = 0
74
+ for filename in out.split("\0"):
75
+ if not filename:
76
+ continue
77
+ try:
78
+ total += (self.path / filename).stat().st_size
79
+ except OSError:
80
+ pass
81
+ return total
82
+
83
+ def get_status(self) -> RepositoryInfo:
84
+ branch = self.get_current_branch()
85
+
86
+ code, out, err = self._run_git("fetch", "--dry-run")
87
+ if code != 0:
88
+ return RepositoryInfo(self.path, self.name, RepoStatus.UNKNOWN, branch, err)
89
+
90
+ code, ahead_behind, _ = self._run_git("rev-list", "--left-right", "--count", "@{u}...HEAD")
91
+
92
+ if code != 0:
93
+ return RepositoryInfo(
94
+ self.path, self.name, RepoStatus.UNKNOWN, branch, "No tracking branch"
95
+ )
96
+
97
+ try:
98
+ behind, ahead = map(int, ahead_behind.split())
99
+ if ahead > 0 and behind > 0:
100
+ status = RepoStatus.DIVERGED
101
+ msg = f"ahead {ahead}, behind {behind}"
102
+ elif ahead > 0:
103
+ status = RepoStatus.AHEAD
104
+ msg = f"ahead {ahead}"
105
+ elif behind > 0:
106
+ status = RepoStatus.BEHIND
107
+ msg = f"behind {behind}"
108
+ else:
109
+ status = RepoStatus.UP_TO_DATE
110
+ msg = ""
111
+ except ValueError:
112
+ status = RepoStatus.UNKNOWN
113
+ msg = "Could not parse git status"
114
+
115
+ code, porcelain, _ = self._run_git("status", "--porcelain", _strip=False)
116
+ staged = False
117
+ unstaged = False
118
+ staged_files: list[str] = []
119
+ unstaged_files: list[str] = []
120
+ if code == 0 and porcelain:
121
+ for line in porcelain.splitlines():
122
+ if len(line) >= 2:
123
+ x, y = line[0], line[1]
124
+ filename = line[3:].strip()
125
+ if x not in (" ", "?"):
126
+ staged = True
127
+ staged_files.append(filename)
128
+ if y not in (" ", "?"):
129
+ unstaged = True
130
+ unstaged_files.append(filename)
131
+
132
+ last_updated = self.get_last_commit_date()
133
+ size = self.get_tracked_size()
134
+
135
+ return RepositoryInfo(
136
+ self.path,
137
+ self.name,
138
+ status,
139
+ branch,
140
+ msg,
141
+ staged,
142
+ unstaged,
143
+ staged_files or None,
144
+ unstaged_files or None,
145
+ last_updated,
146
+ size,
147
+ )
148
+
149
+ def pull(self) -> tuple[bool, str]:
150
+ code, out, err = self._run_git("pull", "--ff-only")
151
+ if code == 0:
152
+ return True, out
153
+ return False, err