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.
@@ -0,0 +1,9 @@
1
+ """Git-related exceptions."""
2
+
3
+
4
+ class GitError(Exception):
5
+ """Raised when a git command fails."""
6
+
7
+ def __init__(self, message: str, stderr: str = "") -> None:
8
+ super().__init__(message)
9
+ self.stderr = stderr
gitux/git/parser.py ADDED
@@ -0,0 +1,83 @@
1
+ """Utilities for parsing git porcelain output."""
2
+
3
+ from gitux.domain import FileStatus
4
+
5
+
6
+ def parse_status(raw: str) -> list[FileStatus]:
7
+ """Parse output of ``git status --porcelain=v1 -z``.
8
+
9
+ Format per entry (NUL-delimited):
10
+ XY<SPACE>path<NUL> # regular file (XY + space + path)
11
+ R<SCORE><SPACE>old<NUL>new<NUL> # rename/copy (XY + score + space + old, then new)
12
+ """
13
+ if not raw:
14
+ return []
15
+
16
+ entries: list[FileStatus] = []
17
+ parts = raw.split("\0")
18
+
19
+ i = 0
20
+ while i < len(parts):
21
+ part = parts[i]
22
+ if not part:
23
+ i += 1
24
+ continue
25
+ if len(part) < 4: # Minimum: XY + space + at least 1 char for path
26
+ i += 1
27
+ continue
28
+
29
+ index_status = part[0]
30
+ worktree_status = part[1]
31
+
32
+ path = part[3:] # Skip XY (2 chars) + space (1 char)
33
+
34
+ if index_status in ("R", "C"):
35
+ if not path: # Empty path means score wasn't separated
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
+ ))
53
+ else:
54
+ i += 1
55
+ entries.append(FileStatus(
56
+ index_status=index_status,
57
+ worktree_status=worktree_status,
58
+ path=path,
59
+ old_path=None,
60
+ ))
61
+
62
+ return entries
63
+
64
+
65
+ def parse_push_output(raw: str) -> tuple[list[str], list[tuple[str, str]]]:
66
+ """Parse output of ``git push --porcelain``.
67
+
68
+ Returns (ok_refs, failed_refs).
69
+ """
70
+ ok_refs: list[str] = []
71
+ failed_refs: list[tuple[str, str]] = []
72
+
73
+ for line in raw.splitlines():
74
+ line = line.strip()
75
+ if line.startswith("ok "):
76
+ ok_refs.append(line[3:])
77
+ elif line.startswith("ng "):
78
+ parts = line[3:].split(" ", 1)
79
+ ref = parts[0] if parts else ""
80
+ reason = parts[1] if len(parts) > 1 else "unknown"
81
+ failed_refs.append((ref, reason))
82
+
83
+ return ok_refs, failed_refs
@@ -0,0 +1,6 @@
1
+ """Presenter layer — business logic orchestration."""
2
+
3
+ from gitux.presenter.commit_presenter import CommitPresenter
4
+ from gitux.presenter.repo_presenter import RepoPresenter
5
+
6
+ __all__ = ["CommitPresenter", "RepoPresenter"]
@@ -0,0 +1,107 @@
1
+ """Presenter for working-tree operations: status, staging, commit, push."""
2
+
3
+ from gitux.domain import CommitResult, FileStatus, PushResult
4
+ from gitux.git import (
5
+ GitError,
6
+ commit as git_commit,
7
+ get_file_diff,
8
+ get_operation_state,
9
+ get_remote_status,
10
+ get_staged_diff,
11
+ get_staged_file_diff,
12
+ get_status,
13
+ get_untracked_file_diff,
14
+ is_detached_head,
15
+ push as git_push,
16
+ stage as git_stage,
17
+ unstage as git_unstage,
18
+ )
19
+
20
+
21
+ class CommitPresenter:
22
+ """Business logic for viewing status, staging, committing, and pushing."""
23
+
24
+ def __init__(self) -> None:
25
+ self._status: list[FileStatus] = []
26
+
27
+ def load_status(self) -> list[FileStatus]:
28
+ """Load and return current file statuses."""
29
+ self._status = get_status()
30
+ return self._status
31
+
32
+ @property
33
+ def staged_files(self) -> list[FileStatus]:
34
+ """Files with staged changes."""
35
+ return [f for f in self._status if f.is_staged]
36
+
37
+ @property
38
+ def unstaged_files(self) -> list[FileStatus]:
39
+ """Files with unstaged changes (including untracked)."""
40
+ return [f for f in self._status if f.is_unstaged]
41
+
42
+ def stage_files(self, paths: list[str]) -> None:
43
+ """Stage the given file paths."""
44
+ git_stage(paths)
45
+
46
+ def unstage_files(self, paths: list[str]) -> None:
47
+ """Unstage the given file paths."""
48
+ git_unstage(paths)
49
+
50
+ def get_diff(self, file_status: FileStatus) -> str:
51
+ """Get diff for a file."""
52
+ if file_status.is_staged:
53
+ return get_staged_file_diff(file_status.path)
54
+ if file_status.index_status == "?" or file_status.worktree_status == "?":
55
+ return get_untracked_file_diff(file_status.path)
56
+ return get_file_diff(file_status.path)
57
+
58
+ def get_staged_preview(self) -> str:
59
+ """Get combined diff of all staged changes."""
60
+ return get_staged_diff()
61
+
62
+ def commit(self, message: str) -> CommitResult:
63
+ """Create a commit with the given message after validation."""
64
+ cleaned = self._clean_message(message)
65
+ if not cleaned:
66
+ return CommitResult(success=False, error="Commit message cannot be empty")
67
+
68
+ if not self.staged_files:
69
+ return CommitResult(success=False, error="No staged changes to commit")
70
+
71
+ try:
72
+ commit_hash = git_commit(cleaned)
73
+ return CommitResult(success=True, commit_hash=commit_hash)
74
+ except GitError as exc:
75
+ return CommitResult(success=False, error=str(exc))
76
+
77
+ def push(self) -> PushResult:
78
+ """Push committed changes to remote."""
79
+ status = get_remote_status()
80
+ if not status.remote:
81
+ return PushResult(success=False, error="No remote configured")
82
+ if status.behind > 0:
83
+ return PushResult(
84
+ success=False,
85
+ error="Remote has new changes. Pull first.",
86
+ )
87
+
88
+ return git_push(remote=status.remote, branch=status.branch)
89
+
90
+ def can_commit(self) -> bool:
91
+ """Whether commit action is allowed."""
92
+ state = get_operation_state()
93
+ if is_detached_head() or state.in_progress:
94
+ return False
95
+ return len(self.staged_files) > 0
96
+
97
+ def can_push(self) -> bool:
98
+ """Whether push action is allowed."""
99
+ status = get_remote_status()
100
+ return status.remote != "" and status.ahead > 0
101
+
102
+ @staticmethod
103
+ def _clean_message(message: str) -> str:
104
+ """Clean commit message: strip, remove comment lines."""
105
+ lines = message.strip().splitlines()
106
+ cleaned = [line for line in lines if not line.lstrip().startswith("#")]
107
+ return "\n".join(cleaned).strip()
@@ -0,0 +1,119 @@
1
+ """Presenter for repository-level operations (branch, remote, repo info)."""
2
+
3
+ from gitux.domain import HeadSummary, OperationState, RemoteStatus, RepoInfo
4
+ from gitux.git import (
5
+ GitError,
6
+ get_branches as git_get_branches,
7
+ get_commit_details as git_get_commit_details,
8
+ get_current_branch,
9
+ get_commit_log as git_get_commit_log,
10
+ get_default_branch as git_get_default_branch,
11
+ get_head_summary as git_get_head_summary,
12
+ get_operation_state as git_get_operation_state,
13
+ get_remote_status,
14
+ get_repo_info,
15
+ get_user as git_get_user,
16
+ is_detached_head,
17
+ is_merge_in_progress as git_is_merge_in_progress,
18
+ is_rebase_in_progress as git_is_rebase_in_progress,
19
+ switch_branch as git_switch_branch,
20
+ )
21
+
22
+
23
+ class RepoPresenter:
24
+ """Business logic for repository queries — branch, remote, identity, log. """
25
+
26
+ def get_current_branch(self) -> str:
27
+ """Return current branch name, or empty string on error."""
28
+ try:
29
+ return get_current_branch()
30
+ except GitError:
31
+ return ""
32
+
33
+ def is_detached_head(self) -> bool:
34
+ """Return True if HEAD is detached, or False on error."""
35
+ try:
36
+ return is_detached_head()
37
+ except GitError:
38
+ return False
39
+
40
+ def get_remote_status(self) -> RemoteStatus | None:
41
+ """Get ahead/behind status for current branch, or None on error."""
42
+ try:
43
+ return get_remote_status()
44
+ except GitError:
45
+ return None
46
+
47
+ def get_repo_info(self) -> RepoInfo | None:
48
+ """Get repository identity (name + path), or None on error."""
49
+ try:
50
+ return get_repo_info()
51
+ except GitError:
52
+ return None
53
+
54
+ def get_commit_log(self, count: int = 30) -> str:
55
+ """Return the ASCII commit log graph, or empty string on error."""
56
+ try:
57
+ return git_get_commit_log(count)
58
+ except GitError:
59
+ return ""
60
+
61
+ def get_commit_details(self, commit_hash: str) -> str:
62
+ """Return commit detail text (hash/author/date/subject/body/stat), or "" on error."""
63
+ try:
64
+ return git_get_commit_details(commit_hash)
65
+ except GitError:
66
+ return ""
67
+
68
+ def get_branches(self) -> list[str]:
69
+ """Return local branch names, or [] on error."""
70
+ try:
71
+ return git_get_branches()
72
+ except GitError:
73
+ return []
74
+
75
+ def switch_branch(self, name: str) -> None:
76
+ """Switch to a local branch. Propagates GitError to the caller."""
77
+ git_switch_branch(name)
78
+
79
+ def get_user(self) -> str:
80
+ """Return the configured git user, or "" on error."""
81
+ try:
82
+ return git_get_user()
83
+ except GitError:
84
+ return ""
85
+
86
+ def get_head_summary(self) -> HeadSummary | None:
87
+ """Return the HEAD commit summary, or None on error."""
88
+ try:
89
+ return git_get_head_summary()
90
+ except GitError:
91
+ return None
92
+
93
+ def get_default_branch(self) -> str:
94
+ """Return the default branch name, or "" on error."""
95
+ try:
96
+ return git_get_default_branch()
97
+ except GitError:
98
+ return ""
99
+
100
+ def get_operation_state(self) -> OperationState | None:
101
+ """Return the merge/rebase operation state, or None on error."""
102
+ try:
103
+ return git_get_operation_state()
104
+ except GitError:
105
+ return None
106
+
107
+ def is_merge_in_progress(self) -> bool:
108
+ """Return True if a merge is in progress, or False on error."""
109
+ try:
110
+ return git_is_merge_in_progress()
111
+ except GitError:
112
+ return False
113
+
114
+ def is_rebase_in_progress(self) -> bool:
115
+ """Return True if a rebase is in progress, or False on error."""
116
+ try:
117
+ return git_is_rebase_in_progress()
118
+ except GitError:
119
+ return False
gitux/ui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Textual TUI components."""