gitux 0.1.0__tar.gz → 0.2.0__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.
- gitux-0.2.0/.github/workflows/ci.yml +56 -0
- {gitux-0.1.0 → gitux-0.2.0}/.gitignore +5 -1
- {gitux-0.1.0 → gitux-0.2.0}/PKG-INFO +3 -2
- {gitux-0.1.0 → gitux-0.2.0}/README.md +2 -1
- {gitux-0.1.0 → gitux-0.2.0}/pyproject.toml +3 -0
- {gitux-0.1.0 → gitux-0.2.0}/scripts/benchmark.py +2 -2
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/__init__.py +1 -1
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/git/__init__.py +22 -14
- gitux-0.2.0/src/gitux/git/branch.py +61 -0
- gitux-0.2.0/src/gitux/git/commands.py +91 -0
- gitux-0.2.0/src/gitux/git/commit.py +43 -0
- gitux-0.2.0/src/gitux/git/config.py +33 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/git/parser.py +28 -20
- gitux-0.2.0/src/gitux/git/remote.py +136 -0
- gitux-0.2.0/src/gitux/git/repo.py +32 -0
- gitux-0.2.0/src/gitux/git/runner.py +52 -0
- gitux-0.2.0/src/gitux/git/status.py +69 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/app.py +19 -87
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/gitux.tcss +75 -39
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/widgets/__init__.py +2 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/widgets/block_container.py +4 -4
- gitux-0.2.0/src/gitux/ui/widgets/commit_log_screen.py +104 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/widgets/diff_viewer.py +12 -13
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/widgets/gitux_footer.py +1 -8
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/widgets/help_screen.py +2 -2
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/widgets/repo_stats_bar.py +92 -87
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_app_smoke.py +59 -163
- gitux-0.2.0/tests/unit/test_block_container.py +52 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_commands.py +77 -58
- gitux-0.2.0/tests/unit/test_commit_log_screen.py +209 -0
- gitux-0.2.0/tests/unit/test_gitux_footer.py +19 -0
- gitux-0.2.0/tests/unit/test_help_screen.py +13 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_parser.py +70 -1
- gitux-0.2.0/tests/unit/test_repo_stats_bar.py +245 -0
- gitux-0.1.0/src/gitux/git/commands.py +0 -392
- gitux-0.1.0/tests/unit/test_block_container.py +0 -22
- gitux-0.1.0/tests/unit/test_repo_stats_bar.py +0 -293
- {gitux-0.1.0 → gitux-0.2.0}/.github/workflows/publish.yml +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/.python-version +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/LICENSE +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/main.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/__main__.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/cli.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/domain/__init__.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/domain/models.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/git/exceptions.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/presenter/__init__.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/presenter/commit_presenter.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/presenter/repo_presenter.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/__init__.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/widgets/branch_screen.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/widgets/changed_files.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/widgets/commit_log.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/widgets/commit_screen.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/ui/widgets/header.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/src/gitux/utils/__init__.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/__init__.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/__init__.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_branch_screen.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_changed_files.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_commit_log.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_commit_screen.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_diff_viewer.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_domain.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_header.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_presenter.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/tests/unit/test_repo_presenter.py +0 -0
- {gitux-0.1.0 → gitux-0.2.0}/uv.lock +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master, develop]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
test:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
strategy:
|
|
19
|
+
fail-fast: false
|
|
20
|
+
matrix:
|
|
21
|
+
python-version: ["3.12", "3.13"]
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- name: Install uv
|
|
26
|
+
uses: astral-sh/setup-uv@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: ${{ matrix.python-version }}
|
|
29
|
+
enable-cache: true
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
run: uv sync --all-extras
|
|
33
|
+
|
|
34
|
+
- name: Run tests
|
|
35
|
+
run: uv run pytest -q
|
|
36
|
+
|
|
37
|
+
build:
|
|
38
|
+
needs: [test]
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/checkout@v4
|
|
42
|
+
|
|
43
|
+
- name: Install uv
|
|
44
|
+
uses: astral-sh/setup-uv@v5
|
|
45
|
+
with:
|
|
46
|
+
python-version: "3.12"
|
|
47
|
+
enable-cache: true
|
|
48
|
+
|
|
49
|
+
- name: Build package
|
|
50
|
+
run: uv build
|
|
51
|
+
|
|
52
|
+
- name: Upload distributions
|
|
53
|
+
uses: actions/upload-artifact@v4
|
|
54
|
+
with:
|
|
55
|
+
name: dist
|
|
56
|
+
path: dist/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gitux
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A beautiful, minimalist TUI for Git.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Hector-2710/gitux
|
|
6
6
|
Project-URL: Repository, https://github.com/Hector-2710/gitux
|
|
@@ -104,7 +104,7 @@ gitux --version
|
|
|
104
104
|
|
|
105
105
|
| Key | Action |
|
|
106
106
|
|-----|--------|
|
|
107
|
-
| `Tab` | Next section (Files /
|
|
107
|
+
| `Tab` | Next section (Files / Diff) |
|
|
108
108
|
| `↑` / `↓` or `j` / `k` | Scroll active section |
|
|
109
109
|
| `s` / `Enter` | Stage / unstage file |
|
|
110
110
|
| `a` / `A` | Stage all / unstage all files |
|
|
@@ -113,6 +113,7 @@ gitux --version
|
|
|
113
113
|
| `Ctrl+p` | Push to remote |
|
|
114
114
|
| `r` | Refresh status |
|
|
115
115
|
| `b` | Branches panel |
|
|
116
|
+
| `l` | Commit log (overlay) |
|
|
116
117
|
| `?` | Show help |
|
|
117
118
|
| `q` | Quit GITUX |
|
|
118
119
|
|
|
@@ -73,7 +73,7 @@ gitux --version
|
|
|
73
73
|
|
|
74
74
|
| Key | Action |
|
|
75
75
|
|-----|--------|
|
|
76
|
-
| `Tab` | Next section (Files /
|
|
76
|
+
| `Tab` | Next section (Files / Diff) |
|
|
77
77
|
| `↑` / `↓` or `j` / `k` | Scroll active section |
|
|
78
78
|
| `s` / `Enter` | Stage / unstage file |
|
|
79
79
|
| `a` / `A` | Stage all / unstage all files |
|
|
@@ -82,6 +82,7 @@ gitux --version
|
|
|
82
82
|
| `Ctrl+p` | Push to remote |
|
|
83
83
|
| `r` | Refresh status |
|
|
84
84
|
| `b` | Branches panel |
|
|
85
|
+
| `l` | Commit log (overlay) |
|
|
85
86
|
| `?` | Show help |
|
|
86
87
|
| `q` | Quit GITUX |
|
|
87
88
|
|
|
@@ -234,8 +234,8 @@ async def _run_app_headless() -> None:
|
|
|
234
234
|
from gitux.ui.app import GituxApp
|
|
235
235
|
|
|
236
236
|
app = GituxApp()
|
|
237
|
-
async with app.run_test(headless=True):
|
|
238
|
-
|
|
237
|
+
async with app.run_test(headless=True) as pilot:
|
|
238
|
+
await pilot.pause() # ensure first render completes before exiting
|
|
239
239
|
|
|
240
240
|
|
|
241
241
|
def _benchmark_startup() -> float:
|
|
@@ -1,30 +1,38 @@
|
|
|
1
1
|
"""Git infrastructure layer."""
|
|
2
2
|
|
|
3
3
|
from gitux.git.exceptions import GitError
|
|
4
|
-
from gitux.git.
|
|
5
|
-
commit,
|
|
4
|
+
from gitux.git.branch import (
|
|
6
5
|
get_branches,
|
|
7
|
-
get_commit_details,
|
|
8
|
-
get_commit_log,
|
|
9
6
|
get_current_branch,
|
|
10
7
|
get_default_branch,
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
is_detached_head,
|
|
9
|
+
switch_branch,
|
|
10
|
+
)
|
|
11
|
+
from gitux.git.commit import (
|
|
12
|
+
commit,
|
|
13
|
+
get_commit_details,
|
|
14
|
+
get_commit_log,
|
|
13
15
|
get_head_summary,
|
|
14
|
-
|
|
16
|
+
)
|
|
17
|
+
from gitux.git.config import get_user
|
|
18
|
+
from gitux.git.remote import (
|
|
15
19
|
get_remote_status,
|
|
16
20
|
get_repo_info,
|
|
21
|
+
push,
|
|
22
|
+
)
|
|
23
|
+
from gitux.git.repo import (
|
|
24
|
+
get_git_dir,
|
|
25
|
+
get_operation_state,
|
|
26
|
+
is_merge_in_progress,
|
|
27
|
+
is_rebase_in_progress,
|
|
28
|
+
)
|
|
29
|
+
from gitux.git.status import (
|
|
30
|
+
get_file_diff,
|
|
17
31
|
get_staged_diff,
|
|
18
32
|
get_staged_file_diff,
|
|
19
33
|
get_status,
|
|
20
34
|
get_untracked_file_diff,
|
|
21
|
-
get_user,
|
|
22
|
-
is_detached_head,
|
|
23
|
-
is_merge_in_progress,
|
|
24
|
-
is_rebase_in_progress,
|
|
25
|
-
push,
|
|
26
35
|
stage,
|
|
27
|
-
switch_branch,
|
|
28
36
|
unstage,
|
|
29
37
|
)
|
|
30
38
|
|
|
@@ -54,4 +62,4 @@ __all__ = [
|
|
|
54
62
|
"stage",
|
|
55
63
|
"switch_branch",
|
|
56
64
|
"unstage",
|
|
57
|
-
]
|
|
65
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Branch-related git commands."""
|
|
2
|
+
|
|
3
|
+
from gitux.git.exceptions import GitError
|
|
4
|
+
from gitux.git.runner import _run
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_current_branch() -> str:
|
|
8
|
+
"""Return the name of the current branch.
|
|
9
|
+
|
|
10
|
+
Empty repo -> "main" (exit 0); detached HEAD -> "" (exit 0).
|
|
11
|
+
"""
|
|
12
|
+
result = _run(["branch", "--show-current"])
|
|
13
|
+
return result.stdout.strip()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_branches() -> list[str]:
|
|
17
|
+
"""Return list of local branch names from ``git branch``."""
|
|
18
|
+
result = _run(["branch"])
|
|
19
|
+
branches = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
20
|
+
cleaned = []
|
|
21
|
+
for b in branches:
|
|
22
|
+
name = b[2:] if b.startswith("* ") else b
|
|
23
|
+
if name.startswith("("):
|
|
24
|
+
continue
|
|
25
|
+
cleaned.append(name)
|
|
26
|
+
return cleaned
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def switch_branch(name: str) -> None:
|
|
30
|
+
"""Switch to a local branch via ``git switch``. Raises GitError on failure."""
|
|
31
|
+
_run(["switch", name])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_detached_head() -> bool:
|
|
35
|
+
"""Return True if HEAD is detached."""
|
|
36
|
+
try:
|
|
37
|
+
_run(["symbolic-ref", "-q", "HEAD"], timeout=5)
|
|
38
|
+
return False
|
|
39
|
+
except GitError:
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_default_branch() -> str:
|
|
44
|
+
"""Return the default branch name from a single ``git branch -r`` call.
|
|
45
|
+
|
|
46
|
+
Parses stripped lines in order: an ``origin/HEAD -> origin/<name>`` line wins;
|
|
47
|
+
otherwise the ``origin/main``/``origin/master`` convention; else ``""``.
|
|
48
|
+
Never mutates, never touches the network.
|
|
49
|
+
"""
|
|
50
|
+
result = _run(["branch", "-r"])
|
|
51
|
+
lines = [line.strip() for line in result.stdout.splitlines()]
|
|
52
|
+
for line in lines:
|
|
53
|
+
if line.startswith("origin/HEAD") and " -> " in line:
|
|
54
|
+
target = line.split(" -> ", 1)[-1].strip()
|
|
55
|
+
if target.startswith("origin/"):
|
|
56
|
+
return target[len("origin/"):]
|
|
57
|
+
if "origin/main" in lines:
|
|
58
|
+
return "main"
|
|
59
|
+
if "origin/master" in lines:
|
|
60
|
+
return "master"
|
|
61
|
+
return ""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Backward-compatible re-exports of the git command wrappers.
|
|
2
|
+
|
|
3
|
+
The wrappers were split into domain modules (``runner``, ``status``, ``commit``,
|
|
4
|
+
``branch``, ``remote``, ``repo``, ``config``) to reduce per-file complexity.
|
|
5
|
+
This module exists only so that ``from gitux.git.commands import ...`` keeps
|
|
6
|
+
working; prefer importing from ``gitux.git`` directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from gitux.git.branch import (
|
|
10
|
+
get_branches,
|
|
11
|
+
get_current_branch,
|
|
12
|
+
get_default_branch,
|
|
13
|
+
is_detached_head,
|
|
14
|
+
switch_branch,
|
|
15
|
+
)
|
|
16
|
+
from gitux.git.commit import (
|
|
17
|
+
_COMMIT_DETAILS_FORMAT,
|
|
18
|
+
commit,
|
|
19
|
+
get_commit_details,
|
|
20
|
+
get_commit_log,
|
|
21
|
+
get_head_summary,
|
|
22
|
+
)
|
|
23
|
+
from gitux.git.config import (
|
|
24
|
+
_os_login,
|
|
25
|
+
_try_config,
|
|
26
|
+
get_user,
|
|
27
|
+
)
|
|
28
|
+
from gitux.git.remote import (
|
|
29
|
+
_extract_owner,
|
|
30
|
+
_extract_repo_name,
|
|
31
|
+
_PUSH_TIMEOUT,
|
|
32
|
+
get_remote_status,
|
|
33
|
+
get_repo_info,
|
|
34
|
+
push,
|
|
35
|
+
)
|
|
36
|
+
from gitux.git.repo import (
|
|
37
|
+
get_git_dir,
|
|
38
|
+
get_operation_state,
|
|
39
|
+
is_merge_in_progress,
|
|
40
|
+
is_rebase_in_progress,
|
|
41
|
+
)
|
|
42
|
+
from gitux.git.runner import (
|
|
43
|
+
_TIMEOUT,
|
|
44
|
+
_run,
|
|
45
|
+
_run_tolerant,
|
|
46
|
+
)
|
|
47
|
+
from gitux.git.status import (
|
|
48
|
+
get_file_diff,
|
|
49
|
+
get_staged_diff,
|
|
50
|
+
get_staged_file_diff,
|
|
51
|
+
get_status,
|
|
52
|
+
get_untracked_file_diff,
|
|
53
|
+
stage,
|
|
54
|
+
unstage,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"_COMMIT_DETAILS_FORMAT",
|
|
59
|
+
"_PUSH_TIMEOUT",
|
|
60
|
+
"_TIMEOUT",
|
|
61
|
+
"_extract_owner",
|
|
62
|
+
"_extract_repo_name",
|
|
63
|
+
"_os_login",
|
|
64
|
+
"_run",
|
|
65
|
+
"_run_tolerant",
|
|
66
|
+
"_try_config",
|
|
67
|
+
"commit",
|
|
68
|
+
"get_branches",
|
|
69
|
+
"get_commit_details",
|
|
70
|
+
"get_commit_log",
|
|
71
|
+
"get_current_branch",
|
|
72
|
+
"get_default_branch",
|
|
73
|
+
"get_file_diff",
|
|
74
|
+
"get_git_dir",
|
|
75
|
+
"get_head_summary",
|
|
76
|
+
"get_operation_state",
|
|
77
|
+
"get_remote_status",
|
|
78
|
+
"get_repo_info",
|
|
79
|
+
"get_staged_diff",
|
|
80
|
+
"get_staged_file_diff",
|
|
81
|
+
"get_status",
|
|
82
|
+
"get_untracked_file_diff",
|
|
83
|
+
"get_user",
|
|
84
|
+
"is_detached_head",
|
|
85
|
+
"is_merge_in_progress",
|
|
86
|
+
"is_rebase_in_progress",
|
|
87
|
+
"push",
|
|
88
|
+
"stage",
|
|
89
|
+
"switch_branch",
|
|
90
|
+
"unstage",
|
|
91
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Commit-related git commands."""
|
|
2
|
+
|
|
3
|
+
from gitux.domain import HeadSummary
|
|
4
|
+
from gitux.git.runner import _run
|
|
5
|
+
|
|
6
|
+
_COMMIT_DETAILS_FORMAT = "%h %an <%ae>%n%ad%n%n%s%n%n%b"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def commit(message: str) -> str:
|
|
10
|
+
"""Create a commit with the given message. Returns the short hash."""
|
|
11
|
+
_run(["commit", "-m", message])
|
|
12
|
+
result = _run(["rev-parse", "--short", "HEAD"])
|
|
13
|
+
return result.stdout.strip()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_commit_log(count: int = 30) -> str:
|
|
17
|
+
"""Return the commit log with ASCII graph via ``git log --all --oneline --graph --decorate``."""
|
|
18
|
+
result = _run(["log", "--all", "--oneline", "--graph", "--decorate", f"-{count}"])
|
|
19
|
+
return result.stdout
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_commit_details(commit_hash: str) -> str:
|
|
23
|
+
"""Return commit metadata + --stat summary via `git show`. Raises GitError on failure."""
|
|
24
|
+
result = _run([
|
|
25
|
+
"show", f"--format={_COMMIT_DETAILS_FORMAT}", "--stat", "--date=iso", commit_hash,
|
|
26
|
+
])
|
|
27
|
+
return result.stdout
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_head_summary() -> HeadSummary | None:
|
|
31
|
+
"""Return short hash, subject, and epoch of HEAD, or None when malformed.
|
|
32
|
+
|
|
33
|
+
``GitError`` propagates for empty repos (exit 128) — the presenter converts.
|
|
34
|
+
"""
|
|
35
|
+
result = _run(["log", "-1", "--format=%h%x09%s%x09%ct"])
|
|
36
|
+
parts = result.stdout.strip().split("\t")
|
|
37
|
+
if len(parts) < 3:
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
epoch = int(parts[2])
|
|
41
|
+
except ValueError:
|
|
42
|
+
return None
|
|
43
|
+
return HeadSummary(short_hash=parts[0], subject=parts[1], epoch=epoch)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Git user configuration commands."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from gitux.git.exceptions import GitError
|
|
6
|
+
from gitux.git.runner import _run
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _try_config(key: str) -> str:
|
|
10
|
+
"""Return a git config value, or "" on any git error."""
|
|
11
|
+
try:
|
|
12
|
+
return _run(["config", "--get", key]).stdout.strip()
|
|
13
|
+
except GitError:
|
|
14
|
+
return ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _os_login() -> str:
|
|
18
|
+
"""Return the OS login name, falling back to $USER on failure."""
|
|
19
|
+
try:
|
|
20
|
+
return os.getlogin()
|
|
21
|
+
except OSError:
|
|
22
|
+
return os.environ.get("USER", "")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_user() -> str:
|
|
26
|
+
"""Return the configured git user (name -> email -> OS login). Never raises."""
|
|
27
|
+
name = _try_config("user.name")
|
|
28
|
+
if name:
|
|
29
|
+
return name
|
|
30
|
+
email = _try_config("user.email")
|
|
31
|
+
if email:
|
|
32
|
+
return email
|
|
33
|
+
return _os_login()
|
|
@@ -8,7 +8,10 @@ def parse_status(raw: str) -> list[FileStatus]:
|
|
|
8
8
|
|
|
9
9
|
Format per entry (NUL-delimited):
|
|
10
10
|
XY<SPACE>path<NUL> # regular file (XY + space + path)
|
|
11
|
-
|
|
11
|
+
XY<SPACE>new<NUL>old<NUL> # rename/copy (XY + space + new path, then old path)
|
|
12
|
+
|
|
13
|
+
Note: in the ``-z`` format git reverses the rename field order shown in the
|
|
14
|
+
human format (``from -> to`` becomes ``to`` ``from``) and omits the score.
|
|
12
15
|
"""
|
|
13
16
|
if not raw:
|
|
14
17
|
return []
|
|
@@ -29,27 +32,10 @@ def parse_status(raw: str) -> list[FileStatus]:
|
|
|
29
32
|
index_status = part[0]
|
|
30
33
|
worktree_status = part[1]
|
|
31
34
|
|
|
32
|
-
path = part[3:] # Skip XY (2 chars) + space (1 char)
|
|
35
|
+
path = part[3:] # Skip XY (2 chars) + space (1 char); for renames this is the new path
|
|
33
36
|
|
|
34
37
|
if index_status in ("R", "C"):
|
|
35
|
-
|
|
36
|
-
i += 1
|
|
37
|
-
continue
|
|
38
|
-
score_end = path.find(" ")
|
|
39
|
-
if score_end >= 0:
|
|
40
|
-
old_path = path[score_end + 1:]
|
|
41
|
-
else:
|
|
42
|
-
old_path = path
|
|
43
|
-
# Next part is new_path
|
|
44
|
-
i += 1
|
|
45
|
-
new_path = parts[i] if i < len(parts) else ""
|
|
46
|
-
i += 1
|
|
47
|
-
entries.append(FileStatus(
|
|
48
|
-
index_status=index_status,
|
|
49
|
-
worktree_status=worktree_status,
|
|
50
|
-
path=new_path,
|
|
51
|
-
old_path=old_path,
|
|
52
|
-
))
|
|
38
|
+
i = _parse_rename_entry(parts, i, index_status, worktree_status, path, entries)
|
|
53
39
|
else:
|
|
54
40
|
i += 1
|
|
55
41
|
entries.append(FileStatus(
|
|
@@ -62,6 +48,28 @@ def parse_status(raw: str) -> list[FileStatus]:
|
|
|
62
48
|
return entries
|
|
63
49
|
|
|
64
50
|
|
|
51
|
+
def _parse_rename_entry(
|
|
52
|
+
parts: list[str],
|
|
53
|
+
i: int,
|
|
54
|
+
index_status: str,
|
|
55
|
+
worktree_status: str,
|
|
56
|
+
new_path: str,
|
|
57
|
+
entries: list[FileStatus],
|
|
58
|
+
) -> int:
|
|
59
|
+
"""Parse a rename/copy entry, append it to ``entries``, and return the next index."""
|
|
60
|
+
# ``new_path`` is the destination; the next part holds the old (source) path.
|
|
61
|
+
i += 1
|
|
62
|
+
old_path = parts[i] if i < len(parts) else ""
|
|
63
|
+
i += 1
|
|
64
|
+
entries.append(FileStatus(
|
|
65
|
+
index_status=index_status,
|
|
66
|
+
worktree_status=worktree_status,
|
|
67
|
+
path=new_path,
|
|
68
|
+
old_path=old_path,
|
|
69
|
+
))
|
|
70
|
+
return i
|
|
71
|
+
|
|
72
|
+
|
|
65
73
|
def parse_push_output(raw: str) -> tuple[list[str], list[tuple[str, str]]]:
|
|
66
74
|
"""Parse output of ``git push --porcelain``.
|
|
67
75
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Remote, push, and repository-identity commands."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from gitux.domain import PushResult, RemoteStatus, RepoInfo
|
|
8
|
+
from gitux.git.branch import get_current_branch
|
|
9
|
+
from gitux.git.exceptions import GitError
|
|
10
|
+
from gitux.git.parser import parse_push_output
|
|
11
|
+
from gitux.git.runner import _run
|
|
12
|
+
|
|
13
|
+
_PUSH_TIMEOUT = 60
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def push(remote: str = "origin", branch: str = "") -> PushResult:
|
|
17
|
+
"""Push to remote using ``git push --porcelain``. Returns PushResult."""
|
|
18
|
+
args = ["push", "--porcelain"]
|
|
19
|
+
if remote:
|
|
20
|
+
args.append(remote)
|
|
21
|
+
if branch:
|
|
22
|
+
args.append(branch)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
result = subprocess.run(
|
|
26
|
+
["git", *args],
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
timeout=_PUSH_TIMEOUT,
|
|
30
|
+
)
|
|
31
|
+
except subprocess.TimeoutExpired:
|
|
32
|
+
return PushResult(success=False, error="Push timed out")
|
|
33
|
+
except FileNotFoundError as exc:
|
|
34
|
+
return PushResult(success=False, error=str(exc))
|
|
35
|
+
|
|
36
|
+
ok_refs, failed_refs = parse_push_output(result.stdout)
|
|
37
|
+
|
|
38
|
+
if result.returncode != 0 and not failed_refs:
|
|
39
|
+
failed_refs.append(("refs/*", result.stderr.strip() or "push failed"))
|
|
40
|
+
|
|
41
|
+
return PushResult(
|
|
42
|
+
success=result.returncode == 0,
|
|
43
|
+
pushed_refs=ok_refs,
|
|
44
|
+
failed_refs=failed_refs,
|
|
45
|
+
error=result.stderr.strip() if result.returncode != 0 else None,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_repo_name(url: str) -> str:
|
|
50
|
+
"""Extract the repository name from a remote URL."""
|
|
51
|
+
parsed = urlparse(url)
|
|
52
|
+
if parsed.path:
|
|
53
|
+
name = parsed.path.rsplit("/", 1)[-1]
|
|
54
|
+
else:
|
|
55
|
+
name = url.rsplit("/", 1)[-1]
|
|
56
|
+
|
|
57
|
+
if name.endswith(".git"):
|
|
58
|
+
name = name[:-4]
|
|
59
|
+
|
|
60
|
+
return name
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _extract_owner(url: str) -> str:
|
|
64
|
+
"""Extract the repository owner from a remote URL."""
|
|
65
|
+
parsed = urlparse(url)
|
|
66
|
+
if parsed.scheme in ("https", "http"):
|
|
67
|
+
segments = [s for s in parsed.path.split("/") if s]
|
|
68
|
+
if len(segments) >= 2:
|
|
69
|
+
return segments[-2]
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
if ":" in url:
|
|
73
|
+
path_part = url.rsplit(":", 1)[-1]
|
|
74
|
+
segments = [s for s in path_part.split("/") if s]
|
|
75
|
+
if len(segments) >= 2:
|
|
76
|
+
return segments[-2]
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_repo_info() -> RepoInfo:
|
|
81
|
+
"""Return repository identity (name + owner + absolute working-tree path)."""
|
|
82
|
+
toplevel = _run(["rev-parse", "--show-toplevel"]).stdout.strip()
|
|
83
|
+
|
|
84
|
+
name = ""
|
|
85
|
+
owner = ""
|
|
86
|
+
try:
|
|
87
|
+
url = _run(["remote", "get-url", "origin"]).stdout.strip()
|
|
88
|
+
name = _extract_repo_name(url)
|
|
89
|
+
owner = _extract_owner(url)
|
|
90
|
+
except GitError:
|
|
91
|
+
try:
|
|
92
|
+
result = _run(["remote"])
|
|
93
|
+
remotes = result.stdout.strip().splitlines()
|
|
94
|
+
if remotes:
|
|
95
|
+
url = _run(["remote", "get-url", remotes[0]]).stdout.strip()
|
|
96
|
+
name = _extract_repo_name(url)
|
|
97
|
+
owner = _extract_owner(url)
|
|
98
|
+
except GitError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
if not name and toplevel:
|
|
102
|
+
name = Path(toplevel).name
|
|
103
|
+
|
|
104
|
+
return RepoInfo(name=name, path=toplevel, owner=owner)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_remote_status() -> RemoteStatus:
|
|
108
|
+
"""Return ahead/behind counts and tracking info for the current branch."""
|
|
109
|
+
branch = get_current_branch()
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
result = _run([
|
|
113
|
+
"rev-parse", "--abbrev-ref", f"{branch}@{{upstream}}"
|
|
114
|
+
])
|
|
115
|
+
upstream = result.stdout.strip()
|
|
116
|
+
remote, _, upstream_branch = upstream.partition("/")
|
|
117
|
+
except GitError:
|
|
118
|
+
return RemoteStatus(remote="", branch=branch, ahead=0, behind=0)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
result = _run([
|
|
122
|
+
"rev-list", "--left-right", "--count",
|
|
123
|
+
f"{branch}...{upstream}",
|
|
124
|
+
])
|
|
125
|
+
counts = result.stdout.strip().split("\t")
|
|
126
|
+
ahead = int(counts[0]) if len(counts) > 0 else 0
|
|
127
|
+
behind = int(counts[1]) if len(counts) > 1 else 0
|
|
128
|
+
except (GitError, ValueError):
|
|
129
|
+
ahead, behind = 0, 0
|
|
130
|
+
|
|
131
|
+
return RemoteStatus(
|
|
132
|
+
remote=remote,
|
|
133
|
+
branch=upstream_branch,
|
|
134
|
+
ahead=ahead,
|
|
135
|
+
behind=behind,
|
|
136
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Repository state commands (git dir, in-progress operations)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from gitux.domain import OperationState
|
|
6
|
+
from gitux.git.runner import _run
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_git_dir() -> str:
|
|
10
|
+
"""Return the git directory path via ``git rev-parse --git-dir``."""
|
|
11
|
+
return _run(["rev-parse", "--git-dir"]).stdout.strip()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_operation_state() -> OperationState:
|
|
15
|
+
"""Return merge/rebase in-progress flags from one shared git-dir lookup."""
|
|
16
|
+
git_dir = Path(get_git_dir())
|
|
17
|
+
return OperationState(
|
|
18
|
+
merge=(git_dir / "MERGE_HEAD").exists(),
|
|
19
|
+
rebase=(git_dir / "rebase-merge").exists()
|
|
20
|
+
or (git_dir / "rebase-apply").exists(),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_merge_in_progress() -> bool:
|
|
25
|
+
"""Return True if a merge is in progress."""
|
|
26
|
+
return (Path(get_git_dir()) / "MERGE_HEAD").exists()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_rebase_in_progress() -> bool:
|
|
30
|
+
"""Return True if a rebase is in progress."""
|
|
31
|
+
git_dir = Path(get_git_dir())
|
|
32
|
+
return (git_dir / "rebase-merge").exists() or (git_dir / "rebase-apply").exists()
|