gitux 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitux/__init__.py +3 -0
- gitux/__main__.py +6 -0
- gitux/cli.py +35 -0
- gitux/domain/__init__.py +27 -0
- gitux/domain/models.py +180 -0
- gitux/git/__init__.py +57 -0
- gitux/git/commands.py +392 -0
- gitux/git/exceptions.py +9 -0
- gitux/git/parser.py +83 -0
- gitux/presenter/__init__.py +6 -0
- gitux/presenter/commit_presenter.py +107 -0
- gitux/presenter/repo_presenter.py +119 -0
- gitux/ui/__init__.py +1 -0
- gitux/ui/app.py +452 -0
- gitux/ui/gitux.tcss +323 -0
- gitux/ui/widgets/__init__.py +24 -0
- gitux/ui/widgets/block_container.py +61 -0
- gitux/ui/widgets/branch_screen.py +54 -0
- gitux/ui/widgets/changed_files.py +187 -0
- gitux/ui/widgets/commit_log.py +197 -0
- gitux/ui/widgets/commit_screen.py +110 -0
- gitux/ui/widgets/diff_viewer.py +149 -0
- gitux/ui/widgets/gitux_footer.py +44 -0
- gitux/ui/widgets/header.py +81 -0
- gitux/ui/widgets/help_screen.py +74 -0
- gitux/ui/widgets/repo_stats_bar.py +212 -0
- gitux/utils/__init__.py +1 -0
- gitux-0.1.0.dist-info/METADATA +174 -0
- gitux-0.1.0.dist-info/RECORD +32 -0
- gitux-0.1.0.dist-info/WHEEL +4 -0
- gitux-0.1.0.dist-info/entry_points.txt +2 -0
- gitux-0.1.0.dist-info/licenses/LICENSE +21 -0
gitux/__init__.py
ADDED
gitux/__main__.py
ADDED
gitux/cli.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""CLI entry point using Typer."""
|
|
2
|
+
|
|
3
|
+
from typer import Typer, Option, echo, Exit
|
|
4
|
+
|
|
5
|
+
app = Typer(
|
|
6
|
+
name="gitux",
|
|
7
|
+
help="A beautiful, minimalist TUI for Git.",
|
|
8
|
+
no_args_is_help=True,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command()
|
|
13
|
+
def run(
|
|
14
|
+
version: bool = Option(False, "--version", "-v", help="Show version and exit"),
|
|
15
|
+
):
|
|
16
|
+
"""Launch the GITUX TUI."""
|
|
17
|
+
if version:
|
|
18
|
+
from gitux import __version__
|
|
19
|
+
|
|
20
|
+
echo(f"gitux {__version__}")
|
|
21
|
+
raise Exit()
|
|
22
|
+
|
|
23
|
+
from gitux.ui.app import GituxApp
|
|
24
|
+
|
|
25
|
+
gitux_app = GituxApp()
|
|
26
|
+
gitux_app.run()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
"""Console script entry point."""
|
|
31
|
+
app()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
main()
|
gitux/domain/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Domain layer for GITUX."""
|
|
2
|
+
|
|
3
|
+
from gitux.domain.models import CommitLogEntry
|
|
4
|
+
from gitux.domain.models import CommitResult
|
|
5
|
+
from gitux.domain.models import FileCounts
|
|
6
|
+
from gitux.domain.models import FileStatus
|
|
7
|
+
from gitux.domain.models import HeadSummary
|
|
8
|
+
from gitux.domain.models import OperationState
|
|
9
|
+
from gitux.domain.models import PushResult
|
|
10
|
+
from gitux.domain.models import RemoteStatus
|
|
11
|
+
from gitux.domain.models import RepoInfo
|
|
12
|
+
from gitux.domain.models import WipState
|
|
13
|
+
from gitux.domain.models import derive_wip_state
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"CommitLogEntry",
|
|
17
|
+
"CommitResult",
|
|
18
|
+
"FileCounts",
|
|
19
|
+
"FileStatus",
|
|
20
|
+
"HeadSummary",
|
|
21
|
+
"OperationState",
|
|
22
|
+
"PushResult",
|
|
23
|
+
"RemoteStatus",
|
|
24
|
+
"RepoInfo",
|
|
25
|
+
"WipState",
|
|
26
|
+
"derive_wip_state",
|
|
27
|
+
]
|
gitux/domain/models.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Domain models for GITUX."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class FileStatus:
|
|
9
|
+
"""Represents the status of a single file in the working tree."""
|
|
10
|
+
|
|
11
|
+
index_status: str
|
|
12
|
+
worktree_status: str
|
|
13
|
+
path: str
|
|
14
|
+
old_path: str | None
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def is_staged(self) -> bool:
|
|
18
|
+
"""True if file has staged changes (index_status is not space or ?)."""
|
|
19
|
+
return self.index_status not in (" ", "?")
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def is_unstaged(self) -> bool:
|
|
23
|
+
"""True if file has unstaged changes or is untracked."""
|
|
24
|
+
return self.worktree_status != " "
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def display_status(self) -> str:
|
|
28
|
+
"""Return the primary status character for display."""
|
|
29
|
+
if self.index_status not in (" ", "?"):
|
|
30
|
+
return self.index_status
|
|
31
|
+
return self.worktree_status
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def display_path(self) -> str:
|
|
35
|
+
"""Return formatted path, including old->new for renames."""
|
|
36
|
+
if self.old_path:
|
|
37
|
+
return f"{self.old_path} -> {self.path}"
|
|
38
|
+
return self.path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class CommitResult:
|
|
43
|
+
"""Result of a git commit operation."""
|
|
44
|
+
|
|
45
|
+
success: bool
|
|
46
|
+
commit_hash: str | None = None
|
|
47
|
+
error: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class PushResult:
|
|
52
|
+
"""Result of a git push operation."""
|
|
53
|
+
|
|
54
|
+
success: bool
|
|
55
|
+
pushed_refs: list[str] = field(default_factory=list)
|
|
56
|
+
failed_refs: list[tuple[str, str]] = field(default_factory=list)
|
|
57
|
+
error: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class RepoInfo:
|
|
62
|
+
"""Repository identity extracted from the local clone."""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
path: str
|
|
66
|
+
owner: str = ""
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def display_name(self) -> str:
|
|
70
|
+
"""Human-friendly name for the header."""
|
|
71
|
+
return self.name or "(no name)"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class HeadSummary:
|
|
76
|
+
"""Short hash, subject, and epoch-seconds of the HEAD commit (batched log output)."""
|
|
77
|
+
|
|
78
|
+
short_hash: str
|
|
79
|
+
subject: str
|
|
80
|
+
epoch: int
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class FileCounts:
|
|
85
|
+
"""Partition of the working-tree file count (conflicts excluded from other buckets)."""
|
|
86
|
+
|
|
87
|
+
staged: int
|
|
88
|
+
modified: int
|
|
89
|
+
untracked: int
|
|
90
|
+
conflicts: int
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_status(cls, files: list[FileStatus]) -> "FileCounts":
|
|
94
|
+
"""Partition a status list into staged/modified/untracked/conflicts."""
|
|
95
|
+
|
|
96
|
+
staged = modified = untracked = conflicts = 0
|
|
97
|
+
for f in files:
|
|
98
|
+
if (
|
|
99
|
+
f.index_status == "U"
|
|
100
|
+
or f.worktree_status == "U"
|
|
101
|
+
or (f.index_status == "A" and f.worktree_status == "A")
|
|
102
|
+
or (f.index_status == "D" and f.worktree_status == "D")
|
|
103
|
+
):
|
|
104
|
+
conflicts += 1
|
|
105
|
+
elif f.index_status == "?":
|
|
106
|
+
untracked += 1
|
|
107
|
+
elif f.is_staged:
|
|
108
|
+
staged += 1
|
|
109
|
+
elif f.worktree_status != " ":
|
|
110
|
+
modified += 1
|
|
111
|
+
else:
|
|
112
|
+
modified += 1
|
|
113
|
+
return cls(
|
|
114
|
+
staged=staged,
|
|
115
|
+
modified=modified,
|
|
116
|
+
untracked=untracked,
|
|
117
|
+
conflicts=conflicts,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class OperationState:
|
|
123
|
+
"""Merge/rebase in-progress flags from one shared git-dir lookup."""
|
|
124
|
+
|
|
125
|
+
merge: bool
|
|
126
|
+
rebase: bool
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def in_progress(self) -> bool:
|
|
130
|
+
"""True if a merge or rebase is currently in progress."""
|
|
131
|
+
return self.merge or self.rebase
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class WipState(Enum):
|
|
135
|
+
"""Work-in-progress state rendered as a single colored dot on the top bar."""
|
|
136
|
+
|
|
137
|
+
UNKNOWN = "unknown"
|
|
138
|
+
CLEAN = "clean"
|
|
139
|
+
DIRTY = "dirty"
|
|
140
|
+
CONFLICT = "conflict"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def derive_wip_state(file_counts: FileCounts, operation: OperationState | None, has_commits: bool,) -> WipState:
|
|
144
|
+
"""Derive the WIP dot state with locked precedence CONFLICT > UNKNOWN > DIRTY > CLEAN."""
|
|
145
|
+
|
|
146
|
+
if operation is None:
|
|
147
|
+
return WipState.UNKNOWN
|
|
148
|
+
if operation.in_progress or file_counts.conflicts > 0:
|
|
149
|
+
return WipState.CONFLICT
|
|
150
|
+
if not has_commits:
|
|
151
|
+
return WipState.UNKNOWN
|
|
152
|
+
if (
|
|
153
|
+
file_counts.staged > 0
|
|
154
|
+
or file_counts.modified > 0
|
|
155
|
+
or file_counts.untracked > 0
|
|
156
|
+
):
|
|
157
|
+
return WipState.DIRTY
|
|
158
|
+
return WipState.CLEAN
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class CommitLogEntry:
|
|
163
|
+
"""Represents a single entry in the commit log graph."""
|
|
164
|
+
|
|
165
|
+
raw_line: str
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class RemoteStatus:
|
|
170
|
+
"""Current remote tracking status for the active branch."""
|
|
171
|
+
|
|
172
|
+
remote: str
|
|
173
|
+
branch: str
|
|
174
|
+
ahead: int
|
|
175
|
+
behind: int
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def diverged(self) -> bool:
|
|
179
|
+
"""True if both ahead > 0 and behind > 0."""
|
|
180
|
+
return self.ahead > 0 and self.behind > 0
|
gitux/git/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Git infrastructure layer."""
|
|
2
|
+
|
|
3
|
+
from gitux.git.exceptions import GitError
|
|
4
|
+
from gitux.git.commands import (
|
|
5
|
+
commit,
|
|
6
|
+
get_branches,
|
|
7
|
+
get_commit_details,
|
|
8
|
+
get_commit_log,
|
|
9
|
+
get_current_branch,
|
|
10
|
+
get_default_branch,
|
|
11
|
+
get_file_diff,
|
|
12
|
+
get_git_dir,
|
|
13
|
+
get_head_summary,
|
|
14
|
+
get_operation_state,
|
|
15
|
+
get_remote_status,
|
|
16
|
+
get_repo_info,
|
|
17
|
+
get_staged_diff,
|
|
18
|
+
get_staged_file_diff,
|
|
19
|
+
get_status,
|
|
20
|
+
get_untracked_file_diff,
|
|
21
|
+
get_user,
|
|
22
|
+
is_detached_head,
|
|
23
|
+
is_merge_in_progress,
|
|
24
|
+
is_rebase_in_progress,
|
|
25
|
+
push,
|
|
26
|
+
stage,
|
|
27
|
+
switch_branch,
|
|
28
|
+
unstage,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"GitError",
|
|
33
|
+
"commit",
|
|
34
|
+
"get_branches",
|
|
35
|
+
"get_commit_details",
|
|
36
|
+
"get_commit_log",
|
|
37
|
+
"get_current_branch",
|
|
38
|
+
"get_default_branch",
|
|
39
|
+
"get_file_diff",
|
|
40
|
+
"get_git_dir",
|
|
41
|
+
"get_head_summary",
|
|
42
|
+
"get_operation_state",
|
|
43
|
+
"get_remote_status",
|
|
44
|
+
"get_repo_info",
|
|
45
|
+
"get_staged_diff",
|
|
46
|
+
"get_staged_file_diff",
|
|
47
|
+
"get_status",
|
|
48
|
+
"get_untracked_file_diff",
|
|
49
|
+
"get_user",
|
|
50
|
+
"is_detached_head",
|
|
51
|
+
"is_merge_in_progress",
|
|
52
|
+
"is_rebase_in_progress",
|
|
53
|
+
"push",
|
|
54
|
+
"stage",
|
|
55
|
+
"switch_branch",
|
|
56
|
+
"unstage",
|
|
57
|
+
]
|
gitux/git/commands.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Low-level git command wrappers."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from gitux.domain import FileStatus, HeadSummary, OperationState, PushResult, RemoteStatus, RepoInfo
|
|
9
|
+
from gitux.git.exceptions import GitError
|
|
10
|
+
from gitux.git.parser import parse_push_output, parse_status
|
|
11
|
+
|
|
12
|
+
_TIMEOUT = 30
|
|
13
|
+
_PUSH_TIMEOUT = 60
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run(args: list[str], *, timeout: int = _TIMEOUT) -> subprocess.CompletedProcess[str]:
|
|
17
|
+
"""Run a git command and return the result. Raises GitError on failure."""
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["git", *args],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
timeout=timeout,
|
|
24
|
+
)
|
|
25
|
+
except subprocess.TimeoutExpired as exc:
|
|
26
|
+
raise GitError(f"Git command timed out after {timeout}s") from exc
|
|
27
|
+
except FileNotFoundError as exc:
|
|
28
|
+
raise GitError("git is not installed or not found on PATH") from exc
|
|
29
|
+
|
|
30
|
+
if result.returncode != 0:
|
|
31
|
+
raise GitError(
|
|
32
|
+
f"git {' '.join(args[:3])} failed (exit {result.returncode})",
|
|
33
|
+
stderr=result.stderr.strip(),
|
|
34
|
+
)
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_status() -> list[FileStatus]:
|
|
39
|
+
"""Return all file statuses using ``git status --porcelain=v1 -z -uall``.
|
|
40
|
+
|
|
41
|
+
``-uall`` expands untracked directories so every file is listed
|
|
42
|
+
individually — never a collapsed ``dir/`` entry.
|
|
43
|
+
"""
|
|
44
|
+
result = _run(["status", "--porcelain=v1", "-z", "-uall"])
|
|
45
|
+
return parse_status(result.stdout)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def stage(paths: list[str]) -> None:
|
|
49
|
+
"""Stage files using ``git add``."""
|
|
50
|
+
if not paths:
|
|
51
|
+
return
|
|
52
|
+
_run(["add", "--", *paths])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unstage(paths: list[str]) -> None:
|
|
56
|
+
"""Unstage files using ``git reset HEAD``."""
|
|
57
|
+
if not paths:
|
|
58
|
+
return
|
|
59
|
+
_run(["reset", "HEAD", "--", *paths])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_staged_diff() -> str:
|
|
63
|
+
"""Return the combined diff of all staged changes."""
|
|
64
|
+
result = _run(["diff", "--cached"])
|
|
65
|
+
return result.stdout
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_file_diff(path: str) -> str:
|
|
69
|
+
"""Return the unstaged diff for a single file."""
|
|
70
|
+
try:
|
|
71
|
+
result = _run(["diff", "--", path])
|
|
72
|
+
return result.stdout
|
|
73
|
+
except GitError:
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_staged_file_diff(path: str) -> str:
|
|
78
|
+
"""Return the staged diff for a single file."""
|
|
79
|
+
try:
|
|
80
|
+
result = _run(["diff", "--cached", "--", path])
|
|
81
|
+
return result.stdout
|
|
82
|
+
except GitError:
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _run_tolerant(args: list[str], *, allowed_return_codes: set[int], timeout: int = _TIMEOUT) -> subprocess.CompletedProcess[str]:
|
|
87
|
+
"""Run git, treating allowed_return_codes as success. Raises GitError otherwise."""
|
|
88
|
+
try:
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
["git", *args],
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
timeout=timeout,
|
|
94
|
+
)
|
|
95
|
+
except subprocess.TimeoutExpired as exc:
|
|
96
|
+
raise GitError(f"Git command timed out after {timeout}s") from exc
|
|
97
|
+
except FileNotFoundError as exc:
|
|
98
|
+
raise GitError("git is not installed or not found on PATH") from exc
|
|
99
|
+
if result.returncode not in allowed_return_codes:
|
|
100
|
+
raise GitError(
|
|
101
|
+
f"git {' '.join(args[:3])} failed (exit {result.returncode})",
|
|
102
|
+
stderr=result.stderr.strip(),
|
|
103
|
+
)
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_untracked_file_diff(path: str) -> str:
|
|
108
|
+
"""Unified diff of an untracked file vs /dev/null; "" for binary or empty files."""
|
|
109
|
+
try:
|
|
110
|
+
result = _run_tolerant(
|
|
111
|
+
["diff", "--no-index", "/dev/null", path], allowed_return_codes={0, 1},
|
|
112
|
+
)
|
|
113
|
+
except GitError:
|
|
114
|
+
return ""
|
|
115
|
+
if "Binary files" in result.stdout:
|
|
116
|
+
return ""
|
|
117
|
+
if not any(line.startswith("@@") for line in result.stdout.splitlines()):
|
|
118
|
+
return ""
|
|
119
|
+
return result.stdout
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def commit(message: str) -> str:
|
|
123
|
+
"""Create a commit with the given message. Returns the short hash."""
|
|
124
|
+
_run(["commit", "-m", message])
|
|
125
|
+
result = _run(["rev-parse", "--short", "HEAD"])
|
|
126
|
+
return result.stdout.strip()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def push(remote: str = "origin", branch: str = "") -> PushResult:
|
|
130
|
+
"""Push to remote using ``git push --porcelain``. Returns PushResult."""
|
|
131
|
+
args = ["push", "--porcelain"]
|
|
132
|
+
if remote:
|
|
133
|
+
args.append(remote)
|
|
134
|
+
if branch:
|
|
135
|
+
args.append(branch)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
result = subprocess.run(
|
|
139
|
+
["git", *args],
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
timeout=_PUSH_TIMEOUT,
|
|
143
|
+
)
|
|
144
|
+
except subprocess.TimeoutExpired:
|
|
145
|
+
return PushResult(success=False, error="Push timed out")
|
|
146
|
+
except FileNotFoundError as exc:
|
|
147
|
+
return PushResult(success=False, error=str(exc))
|
|
148
|
+
|
|
149
|
+
ok_refs, failed_refs = parse_push_output(result.stdout)
|
|
150
|
+
|
|
151
|
+
if result.returncode != 0 and not failed_refs:
|
|
152
|
+
failed_refs.append(("refs/*", result.stderr.strip() or "push failed"))
|
|
153
|
+
|
|
154
|
+
return PushResult(
|
|
155
|
+
success=result.returncode == 0,
|
|
156
|
+
pushed_refs=ok_refs,
|
|
157
|
+
failed_refs=failed_refs,
|
|
158
|
+
error=result.stderr.strip() if result.returncode != 0 else None,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_current_branch() -> str:
|
|
163
|
+
"""Return the name of the current branch.
|
|
164
|
+
|
|
165
|
+
Empty repo -> "main" (exit 0); detached HEAD -> "" (exit 0).
|
|
166
|
+
"""
|
|
167
|
+
result = _run(["branch", "--show-current"])
|
|
168
|
+
return result.stdout.strip()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _extract_repo_name(url: str) -> str:
|
|
172
|
+
"""Extract the repository name from a remote URL."""
|
|
173
|
+
parsed = urlparse(url)
|
|
174
|
+
if parsed.path:
|
|
175
|
+
name = parsed.path.rsplit("/", 1)[-1]
|
|
176
|
+
else:
|
|
177
|
+
name = url.rsplit("/", 1)[-1]
|
|
178
|
+
|
|
179
|
+
if name.endswith(".git"):
|
|
180
|
+
name = name[:-4]
|
|
181
|
+
|
|
182
|
+
return name
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _extract_owner(url: str) -> str:
|
|
186
|
+
"""Extract the repository owner from a remote URL."""
|
|
187
|
+
parsed = urlparse(url)
|
|
188
|
+
if parsed.scheme in ("https", "http"):
|
|
189
|
+
segments = [s for s in parsed.path.split("/") if s]
|
|
190
|
+
if len(segments) >= 2:
|
|
191
|
+
return segments[-2]
|
|
192
|
+
return ""
|
|
193
|
+
|
|
194
|
+
if ":" in url:
|
|
195
|
+
path_part = url.rsplit(":", 1)[-1]
|
|
196
|
+
segments = [s for s in path_part.split("/") if s]
|
|
197
|
+
if len(segments) >= 2:
|
|
198
|
+
return segments[-2]
|
|
199
|
+
return ""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_repo_info() -> RepoInfo:
|
|
203
|
+
"""Return repository identity (name + owner + absolute working-tree path)."""
|
|
204
|
+
toplevel = _run(["rev-parse", "--show-toplevel"]).stdout.strip()
|
|
205
|
+
|
|
206
|
+
name = ""
|
|
207
|
+
owner = ""
|
|
208
|
+
try:
|
|
209
|
+
url = _run(["remote", "get-url", "origin"]).stdout.strip()
|
|
210
|
+
name = _extract_repo_name(url)
|
|
211
|
+
owner = _extract_owner(url)
|
|
212
|
+
except GitError:
|
|
213
|
+
try:
|
|
214
|
+
result = _run(["remote"])
|
|
215
|
+
remotes = result.stdout.strip().splitlines()
|
|
216
|
+
if remotes:
|
|
217
|
+
url = _run(["remote", "get-url", remotes[0]]).stdout.strip()
|
|
218
|
+
name = _extract_repo_name(url)
|
|
219
|
+
owner = _extract_owner(url)
|
|
220
|
+
except GitError:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
if not name and toplevel:
|
|
224
|
+
name = Path(toplevel).name
|
|
225
|
+
|
|
226
|
+
return RepoInfo(name=name, path=toplevel, owner=owner)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_remote_status() -> RemoteStatus:
|
|
230
|
+
"""Return ahead/behind counts and tracking info for the current branch."""
|
|
231
|
+
branch = get_current_branch()
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
result = _run([
|
|
235
|
+
"rev-parse", "--abbrev-ref", f"{branch}@{{upstream}}"
|
|
236
|
+
])
|
|
237
|
+
upstream = result.stdout.strip()
|
|
238
|
+
remote, _, upstream_branch = upstream.partition("/")
|
|
239
|
+
except GitError:
|
|
240
|
+
return RemoteStatus(remote="", branch=branch, ahead=0, behind=0)
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
result = _run([
|
|
244
|
+
"rev-list", "--left-right", "--count",
|
|
245
|
+
f"{branch}...{upstream}",
|
|
246
|
+
])
|
|
247
|
+
counts = result.stdout.strip().split("\t")
|
|
248
|
+
ahead = int(counts[0]) if len(counts) > 0 else 0
|
|
249
|
+
behind = int(counts[1]) if len(counts) > 1 else 0
|
|
250
|
+
except (GitError, ValueError):
|
|
251
|
+
ahead, behind = 0, 0
|
|
252
|
+
|
|
253
|
+
return RemoteStatus(
|
|
254
|
+
remote=remote,
|
|
255
|
+
branch=upstream_branch,
|
|
256
|
+
ahead=ahead,
|
|
257
|
+
behind=behind,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def get_branches() -> list[str]:
|
|
262
|
+
"""Return list of local branch names from ``git branch``."""
|
|
263
|
+
result = _run(["branch"])
|
|
264
|
+
branches = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
265
|
+
cleaned = []
|
|
266
|
+
for b in branches:
|
|
267
|
+
name = b[2:] if b.startswith("* ") else b
|
|
268
|
+
if name.startswith("("):
|
|
269
|
+
continue
|
|
270
|
+
cleaned.append(name)
|
|
271
|
+
return cleaned
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def switch_branch(name: str) -> None:
|
|
275
|
+
"""Switch to a local branch via ``git switch``. Raises GitError on failure."""
|
|
276
|
+
_run(["switch", name])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def get_commit_log(count: int = 30) -> str:
|
|
280
|
+
"""Return the commit log with ASCII graph via ``git log --all --oneline --graph --decorate``."""
|
|
281
|
+
result = _run(["log", "--all", "--oneline", "--graph", "--decorate", f"-{count}"])
|
|
282
|
+
return result.stdout
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
_COMMIT_DETAILS_FORMAT = "%h %an <%ae>%n%ad%n%n%s%n%n%b"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def get_commit_details(commit_hash: str) -> str:
|
|
289
|
+
"""Return commit metadata + --stat summary via `git show`. Raises GitError on failure."""
|
|
290
|
+
result = _run([
|
|
291
|
+
"show", f"--format={_COMMIT_DETAILS_FORMAT}", "--stat", "--date=iso", commit_hash,
|
|
292
|
+
])
|
|
293
|
+
return result.stdout
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def is_detached_head() -> bool:
|
|
297
|
+
"""Return True if HEAD is detached."""
|
|
298
|
+
try:
|
|
299
|
+
_run(["symbolic-ref", "-q", "HEAD"], timeout=5)
|
|
300
|
+
return False
|
|
301
|
+
except GitError:
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _try_config(key: str) -> str:
|
|
306
|
+
"""Return a git config value, or "" on any git error."""
|
|
307
|
+
try:
|
|
308
|
+
return _run(["config", "--get", key]).stdout.strip()
|
|
309
|
+
except GitError:
|
|
310
|
+
return ""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _os_login() -> str:
|
|
314
|
+
"""Return the OS login name, falling back to $USER on failure."""
|
|
315
|
+
try:
|
|
316
|
+
return os.getlogin()
|
|
317
|
+
except OSError:
|
|
318
|
+
return os.environ.get("USER", "")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get_user() -> str:
|
|
322
|
+
"""Return the configured git user (name -> email -> OS login). Never raises."""
|
|
323
|
+
name = _try_config("user.name")
|
|
324
|
+
if name:
|
|
325
|
+
return name
|
|
326
|
+
email = _try_config("user.email")
|
|
327
|
+
if email:
|
|
328
|
+
return email
|
|
329
|
+
return _os_login()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def get_head_summary() -> HeadSummary | None:
|
|
333
|
+
"""Return short hash, subject, and epoch of HEAD, or None when malformed.
|
|
334
|
+
|
|
335
|
+
``GitError`` propagates for empty repos (exit 128) — the presenter converts.
|
|
336
|
+
"""
|
|
337
|
+
result = _run(["log", "-1", "--format=%h%x09%s%x09%ct"])
|
|
338
|
+
parts = result.stdout.strip().split("\t")
|
|
339
|
+
if len(parts) < 3:
|
|
340
|
+
return None
|
|
341
|
+
try:
|
|
342
|
+
epoch = int(parts[2])
|
|
343
|
+
except ValueError:
|
|
344
|
+
return None
|
|
345
|
+
return HeadSummary(short_hash=parts[0], subject=parts[1], epoch=epoch)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def get_default_branch() -> str:
|
|
349
|
+
"""Return the default branch name from a single ``git branch -r`` call.
|
|
350
|
+
|
|
351
|
+
Parses stripped lines in order: an ``origin/HEAD -> origin/<name>`` line wins;
|
|
352
|
+
otherwise the ``origin/main``/``origin/master`` convention; else ``""``.
|
|
353
|
+
Never mutates, never touches the network.
|
|
354
|
+
"""
|
|
355
|
+
result = _run(["branch", "-r"])
|
|
356
|
+
lines = [line.strip() for line in result.stdout.splitlines()]
|
|
357
|
+
for line in lines:
|
|
358
|
+
if line.startswith("origin/HEAD") and " -> " in line:
|
|
359
|
+
target = line.split(" -> ", 1)[-1].strip()
|
|
360
|
+
if target.startswith("origin/"):
|
|
361
|
+
return target[len("origin/"):]
|
|
362
|
+
if "origin/main" in lines:
|
|
363
|
+
return "main"
|
|
364
|
+
if "origin/master" in lines:
|
|
365
|
+
return "master"
|
|
366
|
+
return ""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def get_git_dir() -> str:
|
|
370
|
+
"""Return the git directory path via ``git rev-parse --git-dir``."""
|
|
371
|
+
return _run(["rev-parse", "--git-dir"]).stdout.strip()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def get_operation_state() -> OperationState:
|
|
375
|
+
"""Return merge/rebase in-progress flags from one shared git-dir lookup."""
|
|
376
|
+
git_dir = Path(get_git_dir())
|
|
377
|
+
return OperationState(
|
|
378
|
+
merge=(git_dir / "MERGE_HEAD").exists(),
|
|
379
|
+
rebase=(git_dir / "rebase-merge").exists()
|
|
380
|
+
or (git_dir / "rebase-apply").exists(),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def is_merge_in_progress() -> bool:
|
|
385
|
+
"""Return True if a merge is in progress."""
|
|
386
|
+
return (Path(get_git_dir()) / "MERGE_HEAD").exists()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def is_rebase_in_progress() -> bool:
|
|
390
|
+
"""Return True if a rebase is in progress."""
|
|
391
|
+
git_dir = Path(get_git_dir())
|
|
392
|
+
return (git_dir / "rebase-merge").exists() or (git_dir / "rebase-apply").exists()
|