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.
- gitdirector-0.1.5/PKG-INFO +113 -0
- gitdirector-0.1.5/README.md +75 -0
- gitdirector-0.1.5/pyproject.toml +92 -0
- gitdirector-0.1.5/src/gitdirector/__init__.py +3 -0
- gitdirector-0.1.5/src/gitdirector/cli.py +405 -0
- gitdirector-0.1.5/src/gitdirector/config.py +54 -0
- gitdirector-0.1.5/src/gitdirector/manager.py +156 -0
- gitdirector-0.1.5/src/gitdirector/repo.py +153 -0
|
@@ -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,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
|