pygittools 0.2.0__tar.gz → 0.2.2__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.
- {pygittools-0.2.0/pygittools.egg-info → pygittools-0.2.2}/PKG-INFO +1 -2
- {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/README.md +18 -0
- pygittools-0.2.2/hook_samples/pre-receive.branch-permissions +69 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/main.py +35 -1
- pygittools-0.2.2/mcp_server.py +8 -0
- {pygittools-0.2.0 → pygittools-0.2.2/pygittools.egg-info}/PKG-INFO +1 -2
- {pygittools-0.2.0 → pygittools-0.2.2}/pygittools.egg-info/SOURCES.txt +41 -1
- {pygittools-0.2.0 → pygittools-0.2.2}/pygittools.egg-info/entry_points.txt +0 -1
- {pygittools-0.2.0 → pygittools-0.2.2}/pygittools.egg-info/requires.txt +0 -1
- {pygittools-0.2.0 → pygittools-0.2.2}/pyproject.toml +2 -4
- {pygittools-0.2.0 → pygittools-0.2.2}/tags_sync.py +29 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tasks.py +54 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tasks_test.py +37 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/app.py +12 -10
- pygittools-0.2.2/tui/app_test.py +119 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/branches.py +1 -1
- pygittools-0.2.2/tui/branches_test.py +92 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/changes_list.py +18 -7
- pygittools-0.2.2/tui/changes_list_test.py +172 -0
- pygittools-0.2.2/tui/colors.json +258 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/commit_draft_test.py +24 -0
- pygittools-0.2.2/tui/conftest.py +56 -0
- pygittools-0.2.2/tui/coverage_gaps_test.py +300 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/draw.py +1 -1
- pygittools-0.2.2/tui/draw_test.py +56 -0
- pygittools-0.2.2/tui/git_colors.py +607 -0
- pygittools-0.2.2/tui/git_colors_test.py +85 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/input_context.py +35 -14
- pygittools-0.2.2/tui/input_context_test.py +135 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/log_list.py +19 -8
- pygittools-0.2.2/tui/log_list_test.py +118 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/branches.py +9 -10
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/help.py +3 -4
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/home.py +34 -12
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/no_repo.py +1 -2
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/projects.py +5 -6
- pygittools-0.2.2/tui/pages/task_editor.py +392 -0
- pygittools-0.2.2/tui/pages_test.py +213 -0
- pygittools-0.2.2/tui/projects_test.py +162 -0
- pygittools-0.2.2/tui/pygitweb_layout_test.py +129 -0
- pygittools-0.2.2/tui/repo_test.py +26 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/status_bar.py +2 -23
- pygittools-0.2.2/tui/status_bar_test.py +38 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/tabs.py +6 -19
- pygittools-0.2.2/tui/tabs_test.py +16 -0
- pygittools-0.2.2/tui/tasks_list.py +274 -0
- pygittools-0.2.2/tui/ucurses.py +26 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/worktree.py +18 -7
- pygittools-0.2.2/tui/worktree_test.py +162 -0
- pygittools-0.2.0/mcp_server.py +0 -120
- pygittools-0.2.0/tui/log_list_test.py +0 -36
- pygittools-0.2.0/tui/tabs_test.py +0 -10
- {pygittools-0.2.0 → pygittools-0.2.2}/LICENSE +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/README.md +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/__init__.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/__meta__.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/help.md +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/commit-msg.pattern +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/post-commit.notify +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/post-commit.push-remotes +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/post-receive.notify +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/pre-commit.ruff +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/pre-receive.protected-pattern +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hooks.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hooks_notify.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hooks_patterns.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hooks_patterns_test.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hooks_push.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hooks_push_test.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/hooks_ruff.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/merge.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/merge_test.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/pygittools.egg-info/dependency_links.txt +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/pygittools.egg-info/top_level.txt +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/setup.cfg +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tags_sync_test.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tasks_query.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tasks_query_test.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/__init__.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/commit_draft.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/__init__.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/project_label.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/projects.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/pygitweb_layout.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/repo.py +0 -0
- {pygittools-0.2.0 → pygittools-0.2.2}/tui/types.py +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pygittools
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Hooks and task storage in Git
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
License-File: LICENSE
|
|
9
|
-
Requires-Dist: mcp>=1.28.0
|
|
10
9
|
Requires-Dist: pygit2>=1.12.0
|
|
11
10
|
Requires-Dist: uni-curses>=3.1.2
|
|
12
11
|
Dynamic: license-file
|
|
@@ -30,6 +30,24 @@ Environment variables:
|
|
|
30
30
|
- `PYGITWEB_COMMIT_MSG_PATTERN` — message regex (default `^(\S+): (.+)`)
|
|
31
31
|
- `PYGITWEB_REJECT_MERGE_COMMITS` — set to `1`/`true`/`yes` to reject merge commits
|
|
32
32
|
|
|
33
|
+
## pre-receive.branch-permissions
|
|
34
|
+
|
|
35
|
+
Enforce per-branch write permissions based on `auth.json` `oauth_permissions`. For each
|
|
36
|
+
updated branch ref (`refs/heads/*`), the hook:
|
|
37
|
+
|
|
38
|
+
- Builds a `project/branch` subject (e.g. `demo/main`, `demo/dev/alice.feature`).
|
|
39
|
+
- If any `pgw.write_branch.{scope}` keys match that subject, requires a matching grant.
|
|
40
|
+
- Otherwise, falls back to project write (`pgw.write.{project}`) for that project.
|
|
41
|
+
|
|
42
|
+
When no usable configuration or context is available, the hook is a no-op and allows
|
|
43
|
+
the push to proceed.
|
|
44
|
+
|
|
45
|
+
Environment variables:
|
|
46
|
+
|
|
47
|
+
- `PYGITWEB_PRINCIPAL` — principal identity string (email or username) to check grants for.
|
|
48
|
+
- `PYGITWEB_PROJECT` — project path as known to pygitweb (e.g. `demo`, `team/app`).
|
|
49
|
+
- `PYGITWEB_AUTH_CONFIG` — optional auth.json path (default: `~/.pygitweb/auth.json`).
|
|
50
|
+
|
|
33
51
|
## post-commit.push-remotes
|
|
34
52
|
|
|
35
53
|
After each local commit, force-with-lease push the current branch to every configured
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run python
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__VERSION__ = "1"
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pygittools.hooks import HookResult, RefUpdate, parse_ref_updates
|
|
12
|
+
from pygitweb.auth_config import DEFAULT_AUTH_CONFIG_PATH, AuthConfig
|
|
13
|
+
from pygitweb.permissions import PermissionsMap, has_branch_write_permission
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize_env(name: str) -> str | None:
|
|
17
|
+
value = os.environ.get(name)
|
|
18
|
+
if value is None:
|
|
19
|
+
return None
|
|
20
|
+
stripped = value.strip()
|
|
21
|
+
return stripped or None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_permissions() -> PermissionsMap | None:
|
|
25
|
+
config_path_env = _normalize_env("PYGITWEB_AUTH_CONFIG")
|
|
26
|
+
path = Path(config_path_env).expanduser() if config_path_env is not None else DEFAULT_AUTH_CONFIG_PATH
|
|
27
|
+
try:
|
|
28
|
+
text = path.read_text(encoding="utf-8")
|
|
29
|
+
except OSError:
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
config = AuthConfig.model_validate_json(text)
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
return config.oauth_permissions
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_updates(
|
|
39
|
+
principal: str,
|
|
40
|
+
project: str,
|
|
41
|
+
updates: list[RefUpdate],
|
|
42
|
+
permissions: PermissionsMap,
|
|
43
|
+
) -> HookResult:
|
|
44
|
+
for _, _, ref_name in updates:
|
|
45
|
+
if has_branch_write_permission(permissions, principal, project, ref_name):
|
|
46
|
+
continue
|
|
47
|
+
sys.stderr.write(
|
|
48
|
+
f"Push rejected: {principal!r} lacks branch write permission for project {project!r} ref {ref_name!r}.\n",
|
|
49
|
+
)
|
|
50
|
+
return HookResult.FAILURE
|
|
51
|
+
return HookResult.SUCCESS
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main() -> int:
|
|
55
|
+
principal = _normalize_env("PYGITWEB_PRINCIPAL")
|
|
56
|
+
project = _normalize_env("PYGITWEB_PROJECT")
|
|
57
|
+
if principal is None or project is None:
|
|
58
|
+
return int(HookResult.SUCCESS.value)
|
|
59
|
+
permissions = _load_permissions()
|
|
60
|
+
if permissions is None:
|
|
61
|
+
return int(HookResult.SUCCESS.value)
|
|
62
|
+
updates = parse_ref_updates(sys.stdin)
|
|
63
|
+
result = _check_updates(principal, project, updates, permissions)
|
|
64
|
+
return int(result.value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
raise SystemExit(main())
|
|
69
|
+
|
|
@@ -7,7 +7,8 @@ import sys
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
import pygittools
|
|
10
|
-
from pygittools.tags_sync import format_sync_result, sync_board_tags
|
|
10
|
+
from pygittools.tags_sync import delete_task_remote, format_sync_result, sync_board_tags
|
|
11
|
+
from pygittools.tasks import TASK_REF_PREFIX, delete_task_local
|
|
11
12
|
from pygittools.tasks_query import open_repository, resolve_repo_location
|
|
12
13
|
from pygittools.tui import run_tui
|
|
13
14
|
|
|
@@ -42,6 +43,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
42
43
|
default="Tasks",
|
|
43
44
|
help="Board name under refs/tags/boards/ (default: Tasks)",
|
|
44
45
|
)
|
|
46
|
+
delete_parser = tags_subparsers.add_parser("delete-task", help="Delete a task tag locally and on a remote")
|
|
47
|
+
delete_parser.add_argument(
|
|
48
|
+
"task",
|
|
49
|
+
help="Task ref or ID (for example refs/tags/tasks/alpha or alpha)",
|
|
50
|
+
)
|
|
51
|
+
delete_parser.add_argument(
|
|
52
|
+
"--remote",
|
|
53
|
+
default="origin",
|
|
54
|
+
help="Remote name to delete from (for example origin)",
|
|
55
|
+
)
|
|
45
56
|
return parser
|
|
46
57
|
|
|
47
58
|
|
|
@@ -57,6 +68,27 @@ def _run_tags_sync(repo_path: Path | None, remote: str, board: str) -> int:
|
|
|
57
68
|
return 0
|
|
58
69
|
|
|
59
70
|
|
|
71
|
+
def _normalize_task_ref(task: str) -> str:
|
|
72
|
+
if task.startswith("refs/"):
|
|
73
|
+
return task
|
|
74
|
+
return f"{TASK_REF_PREFIX}{task}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run_task_delete(repo_path: Path | None, remote: str | None, task: str) -> int:
|
|
78
|
+
location = resolve_repo_location(repo_path=repo_path)
|
|
79
|
+
repo = open_repository(location)
|
|
80
|
+
task_ref = _normalize_task_ref(task)
|
|
81
|
+
delete_task_local(repo, task_ref)
|
|
82
|
+
if not remote:
|
|
83
|
+
return 0
|
|
84
|
+
try:
|
|
85
|
+
delete_task_remote(repo, remote, task_ref)
|
|
86
|
+
except (KeyError, RuntimeError, ValueError) as exc:
|
|
87
|
+
print(exc, file=sys.stderr)
|
|
88
|
+
return 1
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
60
92
|
def main(argv: list[str] | None = None) -> int:
|
|
61
93
|
args_list = sys.argv[1:] if argv is None else argv
|
|
62
94
|
if not args_list:
|
|
@@ -67,6 +99,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
67
99
|
|
|
68
100
|
if args.command == "tags" and args.tags_command == "sync":
|
|
69
101
|
return _run_tags_sync(args.repo, args.remote, args.board)
|
|
102
|
+
if args.command == "tags" and args.tags_command == "delete-task":
|
|
103
|
+
return _run_task_delete(args.repo, args.remote, args.task)
|
|
70
104
|
|
|
71
105
|
return run_tui(args.repo)
|
|
72
106
|
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pygittools
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Hooks and task storage in Git
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
License-File: LICENSE
|
|
9
|
-
Requires-Dist: mcp>=1.28.0
|
|
10
9
|
Requires-Dist: pygit2>=1.12.0
|
|
11
10
|
Requires-Dist: uni-curses>=3.1.2
|
|
12
11
|
Dynamic: license-file
|
|
@@ -47,38 +47,59 @@ tasks_test.py
|
|
|
47
47
|
./hook_samples/post-commit.push-remotes
|
|
48
48
|
./hook_samples/post-receive.notify
|
|
49
49
|
./hook_samples/pre-commit.ruff
|
|
50
|
+
./hook_samples/pre-receive.branch-permissions
|
|
50
51
|
./hook_samples/pre-receive.protected-pattern
|
|
51
52
|
./tui/__init__.py
|
|
52
53
|
./tui/app.py
|
|
54
|
+
./tui/app_test.py
|
|
53
55
|
./tui/branches.py
|
|
56
|
+
./tui/branches_test.py
|
|
54
57
|
./tui/changes_list.py
|
|
58
|
+
./tui/changes_list_test.py
|
|
59
|
+
./tui/colors.json
|
|
55
60
|
./tui/commit_draft.py
|
|
56
61
|
./tui/commit_draft_test.py
|
|
62
|
+
./tui/conftest.py
|
|
63
|
+
./tui/coverage_gaps_test.py
|
|
57
64
|
./tui/draw.py
|
|
65
|
+
./tui/draw_test.py
|
|
66
|
+
./tui/git_colors.py
|
|
67
|
+
./tui/git_colors_test.py
|
|
58
68
|
./tui/input_context.py
|
|
69
|
+
./tui/input_context_test.py
|
|
59
70
|
./tui/log_list.py
|
|
60
71
|
./tui/log_list_test.py
|
|
72
|
+
./tui/pages_test.py
|
|
61
73
|
./tui/project_label.py
|
|
62
74
|
./tui/projects.py
|
|
75
|
+
./tui/projects_test.py
|
|
63
76
|
./tui/pygitweb_layout.py
|
|
77
|
+
./tui/pygitweb_layout_test.py
|
|
64
78
|
./tui/repo.py
|
|
79
|
+
./tui/repo_test.py
|
|
65
80
|
./tui/status_bar.py
|
|
81
|
+
./tui/status_bar_test.py
|
|
66
82
|
./tui/tabs.py
|
|
67
83
|
./tui/tabs_test.py
|
|
84
|
+
./tui/tasks_list.py
|
|
68
85
|
./tui/types.py
|
|
86
|
+
./tui/ucurses.py
|
|
69
87
|
./tui/worktree.py
|
|
88
|
+
./tui/worktree_test.py
|
|
70
89
|
./tui/pages/__init__.py
|
|
71
90
|
./tui/pages/branches.py
|
|
72
91
|
./tui/pages/help.py
|
|
73
92
|
./tui/pages/home.py
|
|
74
93
|
./tui/pages/no_repo.py
|
|
75
94
|
./tui/pages/projects.py
|
|
95
|
+
./tui/pages/task_editor.py
|
|
76
96
|
hook_samples/README.md
|
|
77
97
|
hook_samples/commit-msg.pattern
|
|
78
98
|
hook_samples/post-commit.notify
|
|
79
99
|
hook_samples/post-commit.push-remotes
|
|
80
100
|
hook_samples/post-receive.notify
|
|
81
101
|
hook_samples/pre-commit.ruff
|
|
102
|
+
hook_samples/pre-receive.branch-permissions
|
|
82
103
|
hook_samples/pre-receive.protected-pattern
|
|
83
104
|
pygittools.egg-info/PKG-INFO
|
|
84
105
|
pygittools.egg-info/SOURCES.txt
|
|
@@ -88,26 +109,45 @@ pygittools.egg-info/requires.txt
|
|
|
88
109
|
pygittools.egg-info/top_level.txt
|
|
89
110
|
tui/__init__.py
|
|
90
111
|
tui/app.py
|
|
112
|
+
tui/app_test.py
|
|
91
113
|
tui/branches.py
|
|
114
|
+
tui/branches_test.py
|
|
92
115
|
tui/changes_list.py
|
|
116
|
+
tui/changes_list_test.py
|
|
117
|
+
tui/colors.json
|
|
93
118
|
tui/commit_draft.py
|
|
94
119
|
tui/commit_draft_test.py
|
|
120
|
+
tui/conftest.py
|
|
121
|
+
tui/coverage_gaps_test.py
|
|
95
122
|
tui/draw.py
|
|
123
|
+
tui/draw_test.py
|
|
124
|
+
tui/git_colors.py
|
|
125
|
+
tui/git_colors_test.py
|
|
96
126
|
tui/input_context.py
|
|
127
|
+
tui/input_context_test.py
|
|
97
128
|
tui/log_list.py
|
|
98
129
|
tui/log_list_test.py
|
|
130
|
+
tui/pages_test.py
|
|
99
131
|
tui/project_label.py
|
|
100
132
|
tui/projects.py
|
|
133
|
+
tui/projects_test.py
|
|
101
134
|
tui/pygitweb_layout.py
|
|
135
|
+
tui/pygitweb_layout_test.py
|
|
102
136
|
tui/repo.py
|
|
137
|
+
tui/repo_test.py
|
|
103
138
|
tui/status_bar.py
|
|
139
|
+
tui/status_bar_test.py
|
|
104
140
|
tui/tabs.py
|
|
105
141
|
tui/tabs_test.py
|
|
142
|
+
tui/tasks_list.py
|
|
106
143
|
tui/types.py
|
|
144
|
+
tui/ucurses.py
|
|
107
145
|
tui/worktree.py
|
|
146
|
+
tui/worktree_test.py
|
|
108
147
|
tui/pages/__init__.py
|
|
109
148
|
tui/pages/branches.py
|
|
110
149
|
tui/pages/help.py
|
|
111
150
|
tui/pages/home.py
|
|
112
151
|
tui/pages/no_repo.py
|
|
113
|
-
tui/pages/projects.py
|
|
152
|
+
tui/pages/projects.py
|
|
153
|
+
tui/pages/task_editor.py
|
|
@@ -4,21 +4,19 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pygittools"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Hooks and task storage in Git"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
license-files = ["LICENSE"]
|
|
12
12
|
requires-python = ">=3.11"
|
|
13
13
|
dependencies = [
|
|
14
|
-
"mcp>=1.28.0",
|
|
15
14
|
"pygit2>=1.12.0",
|
|
16
15
|
"uni-curses>=3.1.2",
|
|
17
16
|
]
|
|
18
17
|
|
|
19
18
|
[project.scripts]
|
|
20
19
|
pgt = "pygittools.main:main"
|
|
21
|
-
pygittools-mcp = "pygittools.mcp_server:main"
|
|
22
20
|
|
|
23
21
|
[tool.setuptools]
|
|
24
22
|
packages = ["pygittools", "pygittools.tui", "pygittools.tui.pages"]
|
|
@@ -27,4 +25,4 @@ packages = ["pygittools", "pygittools.tui", "pygittools.tui.pages"]
|
|
|
27
25
|
pygittools = "."
|
|
28
26
|
|
|
29
27
|
[tool.setuptools.package-data]
|
|
30
|
-
pygittools = ["hook_samples/**/*", "help.md"]
|
|
28
|
+
pygittools = ["hook_samples/**/*", "help.md", "tui/colors.json"]
|
|
@@ -325,6 +325,35 @@ def push_board_tags(
|
|
|
325
325
|
return tuple(pushed), True
|
|
326
326
|
|
|
327
327
|
|
|
328
|
+
def delete_task_remote(
|
|
329
|
+
repo: Repository,
|
|
330
|
+
remote_name: str,
|
|
331
|
+
task_ref: str,
|
|
332
|
+
*,
|
|
333
|
+
git: str | None = None,
|
|
334
|
+
) -> None:
|
|
335
|
+
if remote_name not in repo.remotes.names():
|
|
336
|
+
raise ValueError(f"remote {remote_name!r} not configured")
|
|
337
|
+
|
|
338
|
+
task_name = task_ref
|
|
339
|
+
if not task_name.startswith("refs/") and not task_name.startswith(TASK_REF_PREFIX):
|
|
340
|
+
task_name = f"{TASK_REF_PREFIX}{task_name}"
|
|
341
|
+
|
|
342
|
+
refspecs: list[str] = [f":{task_name}"]
|
|
343
|
+
task = get_task(repo, task_name)
|
|
344
|
+
if task is not None:
|
|
345
|
+
for oid_hex in getattr(task, "comments", []) or []:
|
|
346
|
+
try:
|
|
347
|
+
oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
|
|
348
|
+
comment = get_comment(repo, oid)
|
|
349
|
+
except (ValueError, KeyError, TypeError):
|
|
350
|
+
continue
|
|
351
|
+
refspecs.append(f":{comment.name}")
|
|
352
|
+
|
|
353
|
+
proc = _run_git(repo, "push", remote_name, *refspecs, git=git)
|
|
354
|
+
_require_git_ok(proc, f"delete {task_name}")
|
|
355
|
+
|
|
356
|
+
|
|
328
357
|
def sync_board_tags(
|
|
329
358
|
repo: Repository,
|
|
330
359
|
remote_name: str,
|
|
@@ -108,6 +108,7 @@ class Task:
|
|
|
108
108
|
priority: Priority | None = None,
|
|
109
109
|
assignee: str | None = None,
|
|
110
110
|
due_date: datetime | None = None,
|
|
111
|
+
tags: str = "",
|
|
111
112
|
):
|
|
112
113
|
if name is None:
|
|
113
114
|
name = f"{TASK_REF_PREFIX}{title.lower().replace(' ', '_')}"
|
|
@@ -124,6 +125,7 @@ class Task:
|
|
|
124
125
|
self.priority = priority
|
|
125
126
|
self.assignee = assignee
|
|
126
127
|
self.due_date = due_date
|
|
128
|
+
self.tags = tags
|
|
127
129
|
self.created_at = datetime.now()
|
|
128
130
|
self.comments: list[Oid | str] = [] # Comment OIDs
|
|
129
131
|
self.update_message()
|
|
@@ -180,6 +182,7 @@ def get_task(repo: Repository, ref: str) -> Task | None:
|
|
|
180
182
|
priority=priority,
|
|
181
183
|
assignee=j.get("assignee"),
|
|
182
184
|
due_date=_parse_dt(j.get("due_date")),
|
|
185
|
+
tags=j.get("tags", "") or "",
|
|
183
186
|
)
|
|
184
187
|
t.comments = j.get("comments", [])
|
|
185
188
|
t.created_at = _parse_dt(j.get("created_at")) or t.created_at
|
|
@@ -205,6 +208,7 @@ def get_task_by_oid(repo: Repository, oid: Oid) -> Task | None:
|
|
|
205
208
|
priority=priority,
|
|
206
209
|
assignee=j.get("assignee"),
|
|
207
210
|
due_date=_parse_dt(j.get("due_date")),
|
|
211
|
+
tags=j.get("tags", "") or "",
|
|
208
212
|
)
|
|
209
213
|
t.comments = j.get("comments", [])
|
|
210
214
|
t.created_at = _parse_dt(j.get("created_at")) or t.created_at
|
|
@@ -331,3 +335,53 @@ def get_board(repo: Repository, ref: str) -> Board | None:
|
|
|
331
335
|
b.created_at = _parse_dt(j.get("created_at")) or b.created_at
|
|
332
336
|
b.updated_at = _parse_dt(j.get("updated_at")) or b.updated_at
|
|
333
337
|
return b
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def delete_task_local(repo: Repository, task_ref: str) -> None:
|
|
341
|
+
try:
|
|
342
|
+
tag_obj = repo.revparse_single(task_ref)
|
|
343
|
+
except (KeyError, ValueError):
|
|
344
|
+
return
|
|
345
|
+
if not isinstance(tag_obj, Tag):
|
|
346
|
+
return
|
|
347
|
+
task = get_task(repo, task_ref)
|
|
348
|
+
task_name = tag_obj.name
|
|
349
|
+
|
|
350
|
+
for ref_name in list(repo.references):
|
|
351
|
+
if not ref_name.startswith(BOARD_REF_PREFIX):
|
|
352
|
+
continue
|
|
353
|
+
board = get_board(repo, ref_name)
|
|
354
|
+
if board is None:
|
|
355
|
+
continue
|
|
356
|
+
original = list(getattr(board, "tasks", []) or [])
|
|
357
|
+
filtered: list[Oid | str] = []
|
|
358
|
+
removed: list[str] = []
|
|
359
|
+
for entry in original:
|
|
360
|
+
try:
|
|
361
|
+
oid = Oid(hex=entry) if isinstance(entry, str) else entry
|
|
362
|
+
board_task = get_task_by_oid(repo, oid)
|
|
363
|
+
except (ValueError, KeyError, TypeError):
|
|
364
|
+
filtered.append(entry)
|
|
365
|
+
continue
|
|
366
|
+
if board_task is None or board_task.name != task_name:
|
|
367
|
+
filtered.append(entry)
|
|
368
|
+
else:
|
|
369
|
+
removed.append(str(oid))
|
|
370
|
+
if not removed:
|
|
371
|
+
continue
|
|
372
|
+
board.tasks = filtered
|
|
373
|
+
board.update_message()
|
|
374
|
+
board.write(repo)
|
|
375
|
+
|
|
376
|
+
if task is not None:
|
|
377
|
+
for oid_hex in getattr(task, "comments", []) or []:
|
|
378
|
+
try:
|
|
379
|
+
oid = Oid(hex=oid_hex) if isinstance(oid_hex, str) else oid_hex
|
|
380
|
+
comment = get_comment(repo, oid)
|
|
381
|
+
except (ValueError, KeyError, TypeError):
|
|
382
|
+
continue
|
|
383
|
+
if comment.name in repo.references:
|
|
384
|
+
repo.references.delete(comment.name)
|
|
385
|
+
|
|
386
|
+
if task_ref in repo.references:
|
|
387
|
+
repo.references.delete(task_ref)
|
|
@@ -11,6 +11,7 @@ from pygittools.tasks import (
|
|
|
11
11
|
Board,
|
|
12
12
|
Comment,
|
|
13
13
|
Task,
|
|
14
|
+
delete_task_local,
|
|
14
15
|
get_board,
|
|
15
16
|
get_comment,
|
|
16
17
|
get_task,
|
|
@@ -201,3 +202,39 @@ def test_full_board_flow_survives_git_gc(repo: Repository, tmp_path: Path) -> No
|
|
|
201
202
|
|
|
202
203
|
contents = [get_comment(reopened, Oid(hex=str(c))).content for c in loaded_task.comments]
|
|
203
204
|
assert contents == ["first", "second"]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_delete_task_local_removes_board_and_comment_refs(repo: Repository) -> None:
|
|
208
|
+
board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Main")
|
|
209
|
+
|
|
210
|
+
task = Task(
|
|
211
|
+
_empty_tree(),
|
|
212
|
+
_task_ref("to-delete"),
|
|
213
|
+
TAGGER,
|
|
214
|
+
title="To Delete",
|
|
215
|
+
description="remove me",
|
|
216
|
+
status=Task.Status.TODO,
|
|
217
|
+
)
|
|
218
|
+
task_oid = task.write(repo)
|
|
219
|
+
|
|
220
|
+
comment = Comment(task_oid, "bob <bob@example.com>", content="bye")
|
|
221
|
+
comment_oid = comment.write(repo)
|
|
222
|
+
|
|
223
|
+
task.comments = [str(comment_oid)]
|
|
224
|
+
task.update_message()
|
|
225
|
+
task_oid = task.write(repo)
|
|
226
|
+
|
|
227
|
+
board.tasks = [str(task_oid)]
|
|
228
|
+
board.update_message()
|
|
229
|
+
board.write(repo)
|
|
230
|
+
|
|
231
|
+
comment_ref = get_comment(repo, comment_oid).name
|
|
232
|
+
|
|
233
|
+
delete_task_local(repo, _task_ref("to-delete"))
|
|
234
|
+
|
|
235
|
+
assert _task_ref("to-delete") not in repo.references
|
|
236
|
+
assert comment_ref not in repo.references
|
|
237
|
+
|
|
238
|
+
loaded_board = get_board(repo, BOARD_MAIN)
|
|
239
|
+
assert loaded_board is not None
|
|
240
|
+
assert loaded_board.tasks == []
|
|
@@ -4,7 +4,14 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
-
from
|
|
7
|
+
from pygittools.tui.git_colors import init_tui_color_theme, status_bar_attr
|
|
8
|
+
from pygittools.tui.pages.home import HomePage, home_page
|
|
9
|
+
from pygittools.tui.pages.no_repo import NoRepoPage
|
|
10
|
+
from pygittools.tui.repo import try_open_page_context
|
|
11
|
+
from pygittools.tui.status_bar import content_height, draw_status_bar
|
|
12
|
+
from pygittools.tui.tabs import init_tab_colors
|
|
13
|
+
from pygittools.tui.types import Page, PageAction, PageContext, PageResult
|
|
14
|
+
from pygittools.tui.ucurses import ( # type: ignore[import-untyped]
|
|
8
15
|
KEY_RESIZE,
|
|
9
16
|
cbreak,
|
|
10
17
|
clear,
|
|
@@ -18,13 +25,6 @@ from unicurses import ( # type: ignore[import-untyped]
|
|
|
18
25
|
refresh,
|
|
19
26
|
)
|
|
20
27
|
|
|
21
|
-
from pygittools.tui.pages.home import HomePage, home_page
|
|
22
|
-
from pygittools.tui.pages.no_repo import NoRepoPage
|
|
23
|
-
from pygittools.tui.repo import try_open_page_context
|
|
24
|
-
from pygittools.tui.status_bar import content_height, draw_status_bar, init_status_bar
|
|
25
|
-
from pygittools.tui.tabs import init_tab_colors
|
|
26
|
-
from pygittools.tui.types import Page, PageAction, PageContext, PageResult
|
|
27
|
-
|
|
28
28
|
|
|
29
29
|
def run_tui(repo_path: Path | None = None) -> int:
|
|
30
30
|
start = (repo_path or Path.cwd()).resolve()
|
|
@@ -38,7 +38,7 @@ def run_tui(repo_path: Path | None = None) -> int:
|
|
|
38
38
|
cbreak()
|
|
39
39
|
curs_set(0)
|
|
40
40
|
keypad(stdscr, True)
|
|
41
|
-
|
|
41
|
+
init_tui_color_theme(ctx.repo if ctx is not None else None)
|
|
42
42
|
init_tab_colors()
|
|
43
43
|
|
|
44
44
|
if ctx is not None:
|
|
@@ -50,7 +50,7 @@ def run_tui(repo_path: Path | None = None) -> int:
|
|
|
50
50
|
clear()
|
|
51
51
|
page.draw(stdscr, content_height(height), width)
|
|
52
52
|
if height > 0:
|
|
53
|
-
draw_status_bar(height - 1, width, page.status_text(),
|
|
53
|
+
draw_status_bar(height - 1, width, page.status_text(), status_bar_attr())
|
|
54
54
|
refresh()
|
|
55
55
|
|
|
56
56
|
try:
|
|
@@ -92,6 +92,8 @@ def _apply_result(
|
|
|
92
92
|
new_ctx = try_open_page_context(result.switch_repo)
|
|
93
93
|
if new_ctx is not None:
|
|
94
94
|
ctx = new_ctx
|
|
95
|
+
init_tui_color_theme(ctx.repo)
|
|
96
|
+
init_tab_colors()
|
|
95
97
|
|
|
96
98
|
match result.action:
|
|
97
99
|
case PageAction.REPLACE:
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from pygit2 import Repository, init_repository
|
|
7
|
+
|
|
8
|
+
from pygittools.tui.app import _apply_result, _persist_commit_draft, run_tui
|
|
9
|
+
from pygittools.tui.pages.home import home_page
|
|
10
|
+
from pygittools.tui.pages.no_repo import NoRepoPage
|
|
11
|
+
from pygittools.tui.types import PageAction, PageContext, PageResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_persist_commit_draft_skips_without_context() -> None:
|
|
15
|
+
_persist_commit_draft([], None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_persist_commit_draft_from_home_page(page_ctx: PageContext) -> None:
|
|
19
|
+
page = home_page()
|
|
20
|
+
page.on_enter(page_ctx)
|
|
21
|
+
page.changes.set_commit_message("draft")
|
|
22
|
+
_persist_commit_draft([page], page_ctx)
|
|
23
|
+
from pygittools.tui.commit_draft import load_commit_draft
|
|
24
|
+
|
|
25
|
+
assert load_commit_draft(page_ctx.repo_path, page_ctx.repo) == "draft"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_apply_result_replace(page_ctx: PageContext) -> None:
|
|
29
|
+
next_page = home_page()
|
|
30
|
+
stack = [NoRepoPage(Path("/tmp"))]
|
|
31
|
+
ctx, new_stack = _apply_result(
|
|
32
|
+
stack,
|
|
33
|
+
page_ctx,
|
|
34
|
+
PageResult(action=PageAction.REPLACE, next_page=next_page),
|
|
35
|
+
)
|
|
36
|
+
assert ctx is page_ctx
|
|
37
|
+
assert new_stack == [next_page]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_apply_result_replace_without_page(page_ctx: PageContext) -> None:
|
|
41
|
+
stack = [home_page()]
|
|
42
|
+
ctx, new_stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.REPLACE))
|
|
43
|
+
assert new_stack is stack
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_apply_result_push_and_pop(page_ctx: PageContext) -> None:
|
|
47
|
+
home = home_page()
|
|
48
|
+
home.on_enter(page_ctx)
|
|
49
|
+
stack = [home]
|
|
50
|
+
overlay = NoRepoPage(Path("/tmp"))
|
|
51
|
+
ctx, stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.PUSH, next_page=overlay))
|
|
52
|
+
assert len(stack) == 2
|
|
53
|
+
ctx, stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.POP))
|
|
54
|
+
assert len(stack) == 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_apply_result_pop_keeps_root(page_ctx: PageContext) -> None:
|
|
58
|
+
home = home_page()
|
|
59
|
+
ctx, stack = _apply_result([home], page_ctx, PageResult(action=PageAction.POP))
|
|
60
|
+
assert stack == [home]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_apply_result_switch_repo(page_ctx: PageContext, tmp_path: Path) -> None:
|
|
64
|
+
repo_dir = tmp_path / "other"
|
|
65
|
+
repo_dir.mkdir()
|
|
66
|
+
init_repository(str(repo_dir), bare=False)
|
|
67
|
+
home = home_page()
|
|
68
|
+
home.on_enter(page_ctx)
|
|
69
|
+
ctx, stack = _apply_result(
|
|
70
|
+
[home],
|
|
71
|
+
page_ctx,
|
|
72
|
+
PageResult(action=PageAction.PUSH, switch_repo=repo_dir),
|
|
73
|
+
)
|
|
74
|
+
assert ctx is not None
|
|
75
|
+
assert stack[-1] is home
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_apply_result_switch_repo_reenters_page(page_ctx: PageContext, tmp_path: Path) -> None:
|
|
79
|
+
repo_dir = tmp_path / "other"
|
|
80
|
+
repo_dir.mkdir()
|
|
81
|
+
init_repository(str(repo_dir), bare=False)
|
|
82
|
+
home = home_page()
|
|
83
|
+
home.on_enter(page_ctx)
|
|
84
|
+
ctx, stack = _apply_result(
|
|
85
|
+
[home],
|
|
86
|
+
page_ctx,
|
|
87
|
+
PageResult(action=PageAction.NONE, switch_repo=repo_dir),
|
|
88
|
+
)
|
|
89
|
+
assert ctx is not None
|
|
90
|
+
assert stack[-1] is home
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _run_tui_once(repo_dir: Path, keys: list[int]) -> int:
|
|
94
|
+
with patch("pygittools.tui.app.initscr") as initscr:
|
|
95
|
+
initscr.return_value = MagicMock()
|
|
96
|
+
with (
|
|
97
|
+
patch("pygittools.tui.app.getmaxyx", return_value=(24, 80)),
|
|
98
|
+
patch("pygittools.tui.app.getch", side_effect=keys),
|
|
99
|
+
patch("pygittools.tui.app.noecho"),
|
|
100
|
+
patch("pygittools.tui.app.cbreak"),
|
|
101
|
+
patch("pygittools.tui.app.curs_set"),
|
|
102
|
+
patch("pygittools.tui.app.keypad"),
|
|
103
|
+
patch("pygittools.tui.app.clear"),
|
|
104
|
+
patch("pygittools.tui.app.refresh"),
|
|
105
|
+
patch("pygittools.tui.app.endwin"),
|
|
106
|
+
patch("pygittools.tui.app.init_tui_color_theme"),
|
|
107
|
+
patch("pygittools.tui.app.init_tab_colors"),
|
|
108
|
+
patch("pygittools.tui.app.draw_status_bar"),
|
|
109
|
+
patch("pygittools.tui.app.status_bar_attr", return_value=0),
|
|
110
|
+
):
|
|
111
|
+
return run_tui(repo_dir)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_run_tui_quits_on_q(committed_repo: Repository) -> None:
|
|
115
|
+
assert _run_tui_once(Path(committed_repo.workdir), [ord("q")]) == 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_run_tui_no_repo(tmp_path: Path) -> None:
|
|
119
|
+
assert _run_tui_once(tmp_path, [ord("q")]) == 0
|