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.
Files changed (86) hide show
  1. {pygittools-0.2.0/pygittools.egg-info → pygittools-0.2.2}/PKG-INFO +1 -2
  2. {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/README.md +18 -0
  3. pygittools-0.2.2/hook_samples/pre-receive.branch-permissions +69 -0
  4. {pygittools-0.2.0 → pygittools-0.2.2}/main.py +35 -1
  5. pygittools-0.2.2/mcp_server.py +8 -0
  6. {pygittools-0.2.0 → pygittools-0.2.2/pygittools.egg-info}/PKG-INFO +1 -2
  7. {pygittools-0.2.0 → pygittools-0.2.2}/pygittools.egg-info/SOURCES.txt +41 -1
  8. {pygittools-0.2.0 → pygittools-0.2.2}/pygittools.egg-info/entry_points.txt +0 -1
  9. {pygittools-0.2.0 → pygittools-0.2.2}/pygittools.egg-info/requires.txt +0 -1
  10. {pygittools-0.2.0 → pygittools-0.2.2}/pyproject.toml +2 -4
  11. {pygittools-0.2.0 → pygittools-0.2.2}/tags_sync.py +29 -0
  12. {pygittools-0.2.0 → pygittools-0.2.2}/tasks.py +54 -0
  13. {pygittools-0.2.0 → pygittools-0.2.2}/tasks_test.py +37 -0
  14. {pygittools-0.2.0 → pygittools-0.2.2}/tui/app.py +12 -10
  15. pygittools-0.2.2/tui/app_test.py +119 -0
  16. {pygittools-0.2.0 → pygittools-0.2.2}/tui/branches.py +1 -1
  17. pygittools-0.2.2/tui/branches_test.py +92 -0
  18. {pygittools-0.2.0 → pygittools-0.2.2}/tui/changes_list.py +18 -7
  19. pygittools-0.2.2/tui/changes_list_test.py +172 -0
  20. pygittools-0.2.2/tui/colors.json +258 -0
  21. {pygittools-0.2.0 → pygittools-0.2.2}/tui/commit_draft_test.py +24 -0
  22. pygittools-0.2.2/tui/conftest.py +56 -0
  23. pygittools-0.2.2/tui/coverage_gaps_test.py +300 -0
  24. {pygittools-0.2.0 → pygittools-0.2.2}/tui/draw.py +1 -1
  25. pygittools-0.2.2/tui/draw_test.py +56 -0
  26. pygittools-0.2.2/tui/git_colors.py +607 -0
  27. pygittools-0.2.2/tui/git_colors_test.py +85 -0
  28. {pygittools-0.2.0 → pygittools-0.2.2}/tui/input_context.py +35 -14
  29. pygittools-0.2.2/tui/input_context_test.py +135 -0
  30. {pygittools-0.2.0 → pygittools-0.2.2}/tui/log_list.py +19 -8
  31. pygittools-0.2.2/tui/log_list_test.py +118 -0
  32. {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/branches.py +9 -10
  33. {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/help.py +3 -4
  34. {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/home.py +34 -12
  35. {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/no_repo.py +1 -2
  36. {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/projects.py +5 -6
  37. pygittools-0.2.2/tui/pages/task_editor.py +392 -0
  38. pygittools-0.2.2/tui/pages_test.py +213 -0
  39. pygittools-0.2.2/tui/projects_test.py +162 -0
  40. pygittools-0.2.2/tui/pygitweb_layout_test.py +129 -0
  41. pygittools-0.2.2/tui/repo_test.py +26 -0
  42. {pygittools-0.2.0 → pygittools-0.2.2}/tui/status_bar.py +2 -23
  43. pygittools-0.2.2/tui/status_bar_test.py +38 -0
  44. {pygittools-0.2.0 → pygittools-0.2.2}/tui/tabs.py +6 -19
  45. pygittools-0.2.2/tui/tabs_test.py +16 -0
  46. pygittools-0.2.2/tui/tasks_list.py +274 -0
  47. pygittools-0.2.2/tui/ucurses.py +26 -0
  48. {pygittools-0.2.0 → pygittools-0.2.2}/tui/worktree.py +18 -7
  49. pygittools-0.2.2/tui/worktree_test.py +162 -0
  50. pygittools-0.2.0/mcp_server.py +0 -120
  51. pygittools-0.2.0/tui/log_list_test.py +0 -36
  52. pygittools-0.2.0/tui/tabs_test.py +0 -10
  53. {pygittools-0.2.0 → pygittools-0.2.2}/LICENSE +0 -0
  54. {pygittools-0.2.0 → pygittools-0.2.2}/README.md +0 -0
  55. {pygittools-0.2.0 → pygittools-0.2.2}/__init__.py +0 -0
  56. {pygittools-0.2.0 → pygittools-0.2.2}/__meta__.py +0 -0
  57. {pygittools-0.2.0 → pygittools-0.2.2}/help.md +0 -0
  58. {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/commit-msg.pattern +0 -0
  59. {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/post-commit.notify +0 -0
  60. {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/post-commit.push-remotes +0 -0
  61. {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/post-receive.notify +0 -0
  62. {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/pre-commit.ruff +0 -0
  63. {pygittools-0.2.0 → pygittools-0.2.2}/hook_samples/pre-receive.protected-pattern +0 -0
  64. {pygittools-0.2.0 → pygittools-0.2.2}/hooks.py +0 -0
  65. {pygittools-0.2.0 → pygittools-0.2.2}/hooks_notify.py +0 -0
  66. {pygittools-0.2.0 → pygittools-0.2.2}/hooks_patterns.py +0 -0
  67. {pygittools-0.2.0 → pygittools-0.2.2}/hooks_patterns_test.py +0 -0
  68. {pygittools-0.2.0 → pygittools-0.2.2}/hooks_push.py +0 -0
  69. {pygittools-0.2.0 → pygittools-0.2.2}/hooks_push_test.py +0 -0
  70. {pygittools-0.2.0 → pygittools-0.2.2}/hooks_ruff.py +0 -0
  71. {pygittools-0.2.0 → pygittools-0.2.2}/merge.py +0 -0
  72. {pygittools-0.2.0 → pygittools-0.2.2}/merge_test.py +0 -0
  73. {pygittools-0.2.0 → pygittools-0.2.2}/pygittools.egg-info/dependency_links.txt +0 -0
  74. {pygittools-0.2.0 → pygittools-0.2.2}/pygittools.egg-info/top_level.txt +0 -0
  75. {pygittools-0.2.0 → pygittools-0.2.2}/setup.cfg +0 -0
  76. {pygittools-0.2.0 → pygittools-0.2.2}/tags_sync_test.py +0 -0
  77. {pygittools-0.2.0 → pygittools-0.2.2}/tasks_query.py +0 -0
  78. {pygittools-0.2.0 → pygittools-0.2.2}/tasks_query_test.py +0 -0
  79. {pygittools-0.2.0 → pygittools-0.2.2}/tui/__init__.py +0 -0
  80. {pygittools-0.2.0 → pygittools-0.2.2}/tui/commit_draft.py +0 -0
  81. {pygittools-0.2.0 → pygittools-0.2.2}/tui/pages/__init__.py +0 -0
  82. {pygittools-0.2.0 → pygittools-0.2.2}/tui/project_label.py +0 -0
  83. {pygittools-0.2.0 → pygittools-0.2.2}/tui/projects.py +0 -0
  84. {pygittools-0.2.0 → pygittools-0.2.2}/tui/pygitweb_layout.py +0 -0
  85. {pygittools-0.2.0 → pygittools-0.2.2}/tui/repo.py +0 -0
  86. {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.0
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
 
@@ -0,0 +1,8 @@
1
+ """Compatibility shim for the MCP server.
2
+
3
+ Use the dedicated ``pygittools-mcp`` package for the actual implementation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pygittools_mcp.mcp_server import * # noqa: F401,F403
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygittools
3
- Version: 0.2.0
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
@@ -1,3 +1,2 @@
1
1
  [console_scripts]
2
2
  pgt = pygittools.main:main
3
- pygittools-mcp = pygittools.mcp_server:main
@@ -1,3 +1,2 @@
1
- mcp>=1.28.0
2
1
  pygit2>=1.12.0
3
2
  uni-curses>=3.1.2
@@ -4,21 +4,19 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pygittools"
7
- version = "0.2.0"
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 unicurses import ( # type: ignore[import-untyped]
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
- status_attr = init_status_bar()
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(), status_attr)
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