gemba-cli 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.
Files changed (43) hide show
  1. gemba/__init__.py +1 -0
  2. gemba/assets/harness/guide.md +88 -0
  3. gemba/assets/init/gemba.yaml +12 -0
  4. gemba/assets/skills/gemba-archive/SKILL.md +78 -0
  5. gemba/assets/skills/gemba-config/SKILL.md +176 -0
  6. gemba/assets/skills/gemba-create/SKILL.md +99 -0
  7. gemba/assets/skills/gemba-harness/SKILL.md +194 -0
  8. gemba/assets/skills/gemba-init/SKILL.md +81 -0
  9. gemba/assets/skills/gemba-list/SKILL.md +137 -0
  10. gemba/assets/skills/gemba-mark/SKILL.md +88 -0
  11. gemba/assets/skills/gemba-record/SKILL.md +89 -0
  12. gemba/assets/skills/gemba-report/SKILL.md +149 -0
  13. gemba/assets/skills/gemba-set/SKILL.md +104 -0
  14. gemba/assets/skills/gemba-worktree/SKILL.md +137 -0
  15. gemba/cli.py +35 -0
  16. gemba/commands/__init__.py +1 -0
  17. gemba/commands/archive.py +62 -0
  18. gemba/commands/config.py +117 -0
  19. gemba/commands/create.py +104 -0
  20. gemba/commands/harness.py +534 -0
  21. gemba/commands/init.py +112 -0
  22. gemba/commands/list.py +51 -0
  23. gemba/commands/mark.py +91 -0
  24. gemba/commands/record.py +113 -0
  25. gemba/commands/report.py +80 -0
  26. gemba/commands/selection.py +170 -0
  27. gemba/commands/set.py +81 -0
  28. gemba/commands/skills.py +102 -0
  29. gemba/commands/worktree.py +292 -0
  30. gemba/config.py +371 -0
  31. gemba/harness.py +401 -0
  32. gemba/metadata.py +442 -0
  33. gemba/repo.py +76 -0
  34. gemba/report.py +153 -0
  35. gemba/skills.py +199 -0
  36. gemba/tasks.py +621 -0
  37. gemba/values.py +16 -0
  38. gemba/worktree.py +191 -0
  39. gemba_cli-0.1.0.dist-info/METADATA +321 -0
  40. gemba_cli-0.1.0.dist-info/RECORD +43 -0
  41. gemba_cli-0.1.0.dist-info/WHEEL +4 -0
  42. gemba_cli-0.1.0.dist-info/entry_points.txt +2 -0
  43. gemba_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,102 @@
1
+ """`gemba skills update`: write the built-in skills of the target, the tasks repo or a harness, per provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from importlib.metadata import version
8
+ from pathlib import Path
9
+
10
+ from gemba import config, harness, skills
11
+
12
+
13
+ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
14
+ parser = subparsers.add_parser(
15
+ "skills",
16
+ help="write the built-in skills into the tasks repo or a harness",
17
+ description="Maintain the skills of Gemba in the skills folder of every agent provider of the target.",
18
+ )
19
+ parser.set_defaults(handler=lambda _args: usage(parser))
20
+ verbs = parser.add_subparsers(dest="verb", metavar="<verb>")
21
+
22
+ update = verbs.add_parser(
23
+ "update",
24
+ help="write missing skills and refresh outdated ones",
25
+ description=(
26
+ f"Write the built-in skills of the target under the skills folder of each of its agent providers. The "
27
+ f"target is the nearest harness above the working directory, with the providers of its "
28
+ f"{harness.CONFIG_FILENAME} and the harness skills, or else the tasks repo, with the providers of "
29
+ "gemba.yaml and the tasks repo skills. A missing skill is written, one written by an older version is "
30
+ "updated, one that matches is kept, and one edited by hand is kept and reported unless --force is given."
31
+ ),
32
+ )
33
+ update.add_argument(
34
+ "--check",
35
+ action="store_true",
36
+ help="print the state of every skill, write nothing, and exit 1 unless every skill is unchanged",
37
+ )
38
+ update.add_argument("--force", action="store_true", help="overwrite skills edited by hand")
39
+ update.add_argument(
40
+ "--root",
41
+ type=Path,
42
+ help=f"target root, a tasks repo or a harness (default: walk up to the nearest {harness.CONFIG_FILENAME} "
43
+ f"or {config.CONFIG_FILENAME})",
44
+ )
45
+ update.set_defaults(handler=run)
46
+
47
+
48
+ def run(args: argparse.Namespace) -> int:
49
+ try:
50
+ root, providers, scope = target(args.root)
51
+ except (config.ConfigError, harness.HarnessError) as exc:
52
+ return fail(str(exc))
53
+
54
+ actions = skills.plan(root, providers, version("gemba-cli"), scope)
55
+ if args.check:
56
+ for action in actions:
57
+ print(f"{action.state} {action.path}")
58
+ return 0 if all(action.state == "unchanged" for action in actions) else 1
59
+
60
+ for action in actions:
61
+ outcome = action.outcome(args.force)
62
+ if outcome != "kept":
63
+ path = root / action.path
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ path.write_text(action.content, encoding="utf-8", newline="\n")
66
+ hint = " (edited by hand; --force overwrites it)" if action.state == "edited" and outcome == "kept" else ""
67
+ print(f"{outcome} {action.path}{hint}")
68
+ return 0
69
+
70
+
71
+ def target(given: Path | None) -> tuple[Path, tuple[str, ...], skills.Target]:
72
+ """The root to write under, its agent providers and its kind: a harness first, else the tasks repo."""
73
+ if given is not None and (given / harness.CONFIG_FILENAME).is_file():
74
+ return harness_target(given.resolve())
75
+ if given is None:
76
+ try:
77
+ found = harness.find_root(Path.cwd())
78
+ except harness.HarnessError:
79
+ pass
80
+ else:
81
+ return harness_target(found)
82
+ gemba_config = config.load(root=given)
83
+ if gemba_config.agent_providers is None:
84
+ raise config.ConfigError(
85
+ f"no agent providers declared: add agent_providers to {config.CONFIG_FILENAME} "
86
+ f"(known providers: {', '.join(skills.PROVIDERS)})"
87
+ )
88
+ return gemba_config.root, gemba_config.agent_providers, "repo"
89
+
90
+
91
+ def harness_target(root: Path) -> tuple[Path, tuple[str, ...], skills.Target]:
92
+ return root, harness.read_config(root, list(skills.PROVIDERS)).agent_providers, "harness"
93
+
94
+
95
+ def usage(parser: argparse.ArgumentParser) -> int:
96
+ parser.print_help(sys.stderr)
97
+ return 1
98
+
99
+
100
+ def fail(message: str) -> int:
101
+ print(f"gemba skills update: {message}", file=sys.stderr)
102
+ return 1
@@ -0,0 +1,292 @@
1
+ """`gemba worktree`: add, list and remove the git worktrees of the repositories a run works on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import textwrap
9
+ from pathlib import Path
10
+
11
+ from gemba import config, harness, skills, worktree
12
+ from gemba.commands import harness as harness_command
13
+
14
+
15
+ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
16
+ parser = subparsers.add_parser(
17
+ "worktree",
18
+ help="add, list and remove the worktrees of the repositories of a run",
19
+ description=(
20
+ f"Check out the repositories of repos_dir as git worktrees under {worktree.WORKTREES_DIR}/ of a "
21
+ "harness, each on the local branch of the run, list them and remove them. Every verb runs inside a "
22
+ "harness; nothing is ever pushed or deleted on the remote."
23
+ ),
24
+ )
25
+ parser.set_defaults(handler=lambda _args: harness_command.usage(parser))
26
+ verbs = parser.add_subparsers(dest="verb", metavar="<verb>")
27
+
28
+ add = verbs.add_parser(
29
+ "add",
30
+ help="create the worktree of a repository, or move it to another base",
31
+ description=textwrap.fill(
32
+ f"Fetch {worktree.REMOTE} with pruning and create {worktree.WORKTREES_DIR}/<repo> on the local branch "
33
+ f"<branch_prefix>/<organization>/<ref>, started at {worktree.REMOTE}/<branch> or at the default branch "
34
+ f"of {worktree.REMOTE}, with no upstream. <repo> is a folder directly under repos_dir that holds a git "
35
+ "repository. When the worktree is already there, nothing changes without --from; with --from its "
36
+ "branch moves to the new base, refused while anything is uncommitted or on no remote branch. The "
37
+ "harness repository is not committed.",
38
+ width=79,
39
+ ),
40
+ )
41
+ add.add_argument("repo", help="name of a folder directly under repos_dir that holds a git repository")
42
+ add.add_argument(
43
+ "--from",
44
+ dest="base",
45
+ metavar="BRANCH",
46
+ help=f"branch of {worktree.REMOTE} to start from (default: the default branch of {worktree.REMOTE})",
47
+ )
48
+ add.add_argument(
49
+ "--force",
50
+ action="store_true",
51
+ help="delete and recreate a branch of the run left without a worktree; with --from, move the branch even "
52
+ "when uncommitted paths or unpushed commits are discarded",
53
+ )
54
+ harness_command.add_location(add)
55
+ add.set_defaults(handler=run_add)
56
+
57
+ list_ = verbs.add_parser(
58
+ "list",
59
+ help="show the branch, upstream and pending work of every worktree",
60
+ description=(
61
+ f"Print, for every folder under {worktree.WORKTREES_DIR}/ in name order, its path, branch, upstream, "
62
+ "the number of uncommitted paths and the number of commits on no remote branch, read from git."
63
+ ),
64
+ )
65
+ harness_command.add_location(list_)
66
+ list_.set_defaults(handler=run_list)
67
+
68
+ remove = verbs.add_parser(
69
+ "remove",
70
+ help="delete the worktree of a repository and its branch, after showing its state",
71
+ description=textwrap.fill(
72
+ f"Show the state of {worktree.WORKTREES_DIR}/<repo>, as list shows it. With --yes, then remove the "
73
+ "worktree from its repository and delete the branch checked out in it. Nothing is deleted while the "
74
+ "worktree has uncommitted paths or commits on no remote branch, unless --force is given. The remote "
75
+ "is never touched.",
76
+ width=79,
77
+ ),
78
+ )
79
+ remove.add_argument("repo", help=f"name of the worktree, the folder under {worktree.WORKTREES_DIR}/")
80
+ remove.add_argument("--yes", action="store_true", help="delete the worktree and its branch after showing them")
81
+ remove.add_argument(
82
+ "--force", action="store_true", help="delete even when uncommitted paths or unpushed commits are discarded"
83
+ )
84
+ harness_command.add_location(remove)
85
+ remove.set_defaults(handler=run_remove)
86
+
87
+
88
+ def run_add(args: argparse.Namespace) -> int:
89
+ try:
90
+ folder = harness_command.locate(args.harness)
91
+ gemba_config = config.load(root=args.root, cwd=folder)
92
+ identity = harness.read_config(folder, list(skills.PROVIDERS))
93
+ except (harness.HarnessError, config.ConfigError) as exc:
94
+ return fail("add", str(exc))
95
+
96
+ path = worktree.relative_path(args.repo)
97
+ target = worktree.worktree_path(folder, args.repo)
98
+ errors = []
99
+ if gemba_config.organization is None:
100
+ errors.append(f"no organization declared: add organization to {config.CONFIG_FILENAME}")
101
+ repository = None
102
+ if gemba_config.repos_dir is None:
103
+ errors.append(f"no repos_dir declared: add repos_dir to {config.CONFIG_FILENAME}")
104
+ else:
105
+ repos = worktree.repos_dir(gemba_config.root, gemba_config.repos_dir)
106
+ if problem := worktree.repo_problem(repos, args.repo, repositories(repos)):
107
+ errors.append(problem)
108
+ else:
109
+ repository = (repos / args.repo).resolve()
110
+ attached = False
111
+ if repository is not None and os.path.lexists(target):
112
+ attached = common_repository(target) == repository
113
+ if not attached:
114
+ errors.append(worktree.folder_problem(path, args.repo))
115
+ if errors:
116
+ return fail("add", refusal(worktree.NOT_ADDED_REFUSAL, errors))
117
+ assert repository is not None and gemba_config.organization is not None, "every failed check appended an error"
118
+
119
+ if attached and args.base is None:
120
+ print(f"{path}\n{worktree.ALREADY_ATTACHED}")
121
+ return 0
122
+ header = worktree.NOT_MOVED_REFUSAL if attached else worktree.NOT_ADDED_REFUSAL
123
+ # The default branch is what the repository records: the fetch never creates or moves it.
124
+ keep_default = f"remote.{worktree.REMOTE}.followRemoteHEAD=never"
125
+ if problem := harness_command.git(repository, "-c", keep_default, "fetch", "--quiet", "--prune", worktree.REMOTE):
126
+ return fail("add", refusal(header, [problem]))
127
+ remote_heads = f"refs/remotes/{worktree.REMOTE}/"
128
+ remote_branches = harness_command.git_lines(repository, "for-each-ref", "--format=%(refname)", remote_heads)
129
+ default = None
130
+ if args.base is None:
131
+ head = harness_command.git_lines(repository, "symbolic-ref", "--quiet", f"{remote_heads}HEAD")
132
+ default = head[0].removeprefix(remote_heads) if head else None
133
+ branches = [line.removeprefix(remote_heads) for line in remote_branches or []]
134
+ if problem := worktree.base_problem(args.base, branches, default):
135
+ return fail("add", refusal(header, [problem]))
136
+ base = args.base or default
137
+ assert base is not None, "base_problem refuses a missing base"
138
+
139
+ if attached:
140
+ return move(folder, args.repo, base, args.force)
141
+ branch = worktree.branch_name(gemba_config.branch_prefix, gemba_config.organization, identity.ref)
142
+ if problem := harness_command.git(repository, "worktree", "prune"):
143
+ return fail("add", refusal(header, [problem]))
144
+ records = worktree.parse_worktrees(harness_command.git_lines(repository, "worktree", "list", "--porcelain") or [])
145
+ if has_branch(repository, branch) and all(record.branch != branch for record in records):
146
+ if not args.force:
147
+ return fail("add", refusal(header, [worktree.existing_branch_problem(args.repo, branch)]))
148
+ if problem := harness_command.git(repository, "branch", "--quiet", "-D", branch):
149
+ return fail("add", refusal(header, [problem]))
150
+ start = worktree.remote_base(base)
151
+ if problem := harness_command.git(
152
+ repository, "worktree", "add", "--quiet", "--no-track", "-b", branch, str(target), start
153
+ ):
154
+ return fail("add", refusal(header, [problem]))
155
+ print(worktree.render_outcome(path, branch, base, worktree.CREATED))
156
+ return 0
157
+
158
+
159
+ def move(folder: Path, repo: str, base: str, force: bool) -> int:
160
+ """Move the branch of an attached worktree to `base`, after the pending checks unless `force`."""
161
+ state = read_state(folder, repo)
162
+ problems = [] if force else worktree.pending_problems(state)
163
+ if problems:
164
+ print(worktree.render_state(state))
165
+ return fail("add", refusal(worktree.NOT_MOVED_REFUSAL, problems))
166
+ target = worktree.worktree_path(folder, repo)
167
+ start = worktree.remote_base(base)
168
+ if problem := harness_command.git(target, "reset", "--quiet", "--hard", start) or harness_command.git(
169
+ target, "clean", "--quiet", "-d", "--force"
170
+ ):
171
+ return fail("add", refusal(worktree.NOT_MOVED_REFUSAL, [problem]))
172
+ print(worktree.render_outcome(state.path, state.branch or "detached", base, worktree.MOVED))
173
+ return 0
174
+
175
+
176
+ def run_list(args: argparse.Namespace) -> int:
177
+ try:
178
+ folder = harness_command.locate(args.harness)
179
+ except harness.HarnessError as exc:
180
+ return fail("list", str(exc))
181
+ names = entries(folder)
182
+ if not names:
183
+ print(worktree.NO_WORKTREES)
184
+ return 0
185
+ print("\n\n".join(worktree.render_state(read_state(folder, name)) for name in names))
186
+ return 0
187
+
188
+
189
+ def run_remove(args: argparse.Namespace) -> int:
190
+ try:
191
+ folder = harness_command.locate(args.harness)
192
+ except harness.HarnessError as exc:
193
+ return fail("remove", str(exc))
194
+
195
+ path = worktree.relative_path(args.repo)
196
+ if args.repo not in entries(folder):
197
+ return fail("remove", refusal(worktree.NOT_REMOVED_REFUSAL, [f"nothing exists at {path}"]))
198
+ state = read_state(folder, args.repo)
199
+ if state.kind == "not a worktree":
200
+ not_a_worktree = f"{path} is not a worktree; delete it by hand if it is not needed"
201
+ return fail("remove", refusal(worktree.NOT_REMOVED_REFUSAL, [not_a_worktree]))
202
+ print(worktree.render_state(state))
203
+ problems = [] if args.force else worktree.pending_problems(state)
204
+ if problems:
205
+ return fail("remove", refusal(worktree.NOT_REMOVED_REFUSAL, problems))
206
+ if not args.yes:
207
+ print(worktree.NOT_REMOVED)
208
+ return 0
209
+ if problem := delete(worktree.worktree_path(folder, args.repo), path, state.branch, args.force):
210
+ return fail("remove", problem)
211
+ print(worktree.REMOVED)
212
+ return 0
213
+
214
+
215
+ def delete(target: Path, path: str, branch: str | None, force: bool) -> str | None:
216
+ """Remove the worktree at `target`, named `path` in messages, and delete `branch` from its repository.
217
+
218
+ None when both succeed, what was left and why otherwise.
219
+ """
220
+ repository = common_repository(target)
221
+ if repository is None:
222
+ return f"{path} was not removed: the repository it belongs to could not be read"
223
+ if problem := harness_command.git(repository, "worktree", "remove", *(["--force"] if force else []), str(target)):
224
+ return f"{path} was not removed: {problem}"
225
+ if branch is not None and (problem := harness_command.git(repository, "branch", "--quiet", "-D", branch)):
226
+ return f"{path} was removed but its branch {branch} was not deleted from {repository}: {problem}"
227
+ return None
228
+
229
+
230
+ def entries(harness_root: Path) -> list[str]:
231
+ """The names under `worktrees/` of the harness, in name order; none when the folder does not exist."""
232
+ folder = harness_root / worktree.WORKTREES_DIR
233
+ return sorted(entry.name for entry in folder.iterdir()) if folder.is_dir() else []
234
+
235
+
236
+ def read_state(harness_root: Path, name: str) -> worktree.State:
237
+ """The state of `worktrees/<name>` of the harness, read from git without writing to any repository."""
238
+ folder = harness_root / worktree.WORKTREES_DIR / name
239
+ path = worktree.relative_path(name)
240
+ if folder.is_symlink() or not folder.is_dir() or common_repository(folder) is None:
241
+ return worktree.State(path, kind="not a worktree")
242
+ branch = harness_command.git_lines(folder, "symbolic-ref", "--quiet", "--short", "HEAD")
243
+ upstream = harness_command.git_lines(folder, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
244
+ status = harness_command.git_lines(folder, "status", "--porcelain", "--untracked-files=all")
245
+ unpushed = harness_command.git_lines(folder, "rev-list", "--count", "HEAD", "--not", "--remotes")
246
+ if status is None or not unpushed:
247
+ return worktree.State(path, kind="unreadable", branch=branch[0] if branch else None)
248
+ return worktree.State(
249
+ path,
250
+ branch=branch[0] if branch else None,
251
+ upstream=upstream[0] if upstream else None,
252
+ uncommitted=tuple(line[3:] for line in status if line.strip()),
253
+ unpushed=int(unpushed[0]),
254
+ )
255
+
256
+
257
+ def common_repository(folder: Path) -> Path | None:
258
+ """The repository `folder` is a linked worktree of, resolved, or None when it is not one."""
259
+ if not (folder / ".git").is_file():
260
+ return None
261
+ lines = harness_command.git_lines(
262
+ folder, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"
263
+ )
264
+ if lines is None or len(lines) != 3:
265
+ return None
266
+ top, git_dir, common = (Path(line).resolve() for line in lines)
267
+ if top != folder.resolve() or git_dir == common:
268
+ return None
269
+ return common.parent if common.name == ".git" else common
270
+
271
+
272
+ def repositories(folder: Path) -> list[str] | None:
273
+ """The folders directly under `folder` that hold a git repository, or None when `folder` is not a folder."""
274
+ if not folder.is_dir():
275
+ return None
276
+ return sorted(entry.name for entry in folder.iterdir() if entry.is_dir() and (entry / ".git").exists())
277
+
278
+
279
+ def has_branch(repository: Path, branch: str) -> bool:
280
+ """Whether the repository holds the local branch `branch`."""
281
+ return harness_command.git_lines(repository, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}") is not None
282
+
283
+
284
+ def refusal(header: str, problems: list[str]) -> str:
285
+ return f"{header}\n" + "\n".join(f"- {problem}" for problem in problems)
286
+
287
+
288
+ def fail(verb: str, message: str) -> int:
289
+ # What was printed before, such as the state of a worktree, comes first in a combined capture.
290
+ sys.stdout.flush()
291
+ print(f"gemba worktree {verb}: {message}", file=sys.stderr)
292
+ return 1