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 ADDED
@@ -0,0 +1,3 @@
1
+ """GITUX - A beautiful, minimalist TUI for Git."""
2
+
3
+ __version__ = "0.1.0"
gitux/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running GITUX with `python -m gitux`."""
2
+
3
+ from gitux.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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()
@@ -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()