kiwi-code 0.0.43__tar.gz → 0.0.431__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 (59) hide show
  1. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/main.py +37 -0
  4. kiwi_code-0.0.431/src/kiwi_tui/worktrees.py +315 -0
  5. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_cli_help.py +3 -0
  6. kiwi_code-0.0.431/tests/test_worktrees.py +272 -0
  7. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/uv.lock +1 -1
  8. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/.github/workflows/publish.yml +0 -0
  9. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/.github/workflows/test.yml +0 -0
  10. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/.gitignore +0 -0
  11. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/.python-version +0 -0
  12. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/CLAUDE.md +0 -0
  13. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/Makefile +0 -0
  14. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/README.md +0 -0
  15. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/__init__.py +0 -0
  16. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/auth.py +0 -0
  17. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/cli.py +0 -0
  18. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/client.py +0 -0
  19. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/commands.py +0 -0
  20. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/logger.py +0 -0
  21. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/models.py +0 -0
  22. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/runtime_manager.py +0 -0
  23. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/server.py +0 -0
  24. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/terminal_mode.py +0 -0
  25. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_runtime/__init__.py +0 -0
  26. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_runtime/__main__.py +0 -0
  27. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_runtime/main.py +0 -0
  28. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  29. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  30. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/__init__.py +0 -0
  31. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/inline_file_picker.py +0 -0
  32. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/random_words.py +0 -0
  33. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/runtime_agent.py +0 -0
  34. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/__init__.py +0 -0
  35. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/attach_content.py +0 -0
  36. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/command_result.py +0 -0
  37. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/dashboard.py +0 -0
  38. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/file_browser.py +0 -0
  39. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/help.py +0 -0
  40. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/id_picker.py +0 -0
  41. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/login.py +0 -0
  42. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  43. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  44. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/slash_picker.py +0 -0
  45. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/slash_commands.py +0 -0
  46. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/status_words.py +0 -0
  47. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/widgets.py +0 -0
  48. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/test_hello.py +0 -0
  49. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/__init__.py +0 -0
  50. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/conftest.py +0 -0
  51. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_imports.py +0 -0
  52. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_reexec_kiwi.py +0 -0
  53. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_runtime_log_trimming.py +0 -0
  54. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_slash_commands.py +0 -0
  55. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_terminal_mode.py +0 -0
  56. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_tokens.py +0 -0
  57. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_tui_headless.py +0 -0
  58. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_tui_interactive_runtime.py +0 -0
  59. {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_tui_palette.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.43
3
+ Version: 0.0.431
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://meetkiwi.ai
6
6
  Project-URL: Repository, https://github.com/jetoslabs/kiwi-code
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.43"
3
+ version = "0.0.431"
4
4
  description = "A textual-based terminal user interface application"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.11,<4.0"
@@ -1109,6 +1109,20 @@ def main() -> int:
1109
1109
  action="store_true",
1110
1110
  help="Run Kiwi in plain terminal mode instead of launching the full-screen TUI.",
1111
1111
  )
1112
+ parser.add_argument(
1113
+ "-w",
1114
+ "--worktree",
1115
+ "-worktree",
1116
+ nargs="?",
1117
+ const="",
1118
+ default=None,
1119
+ metavar="NAME",
1120
+ help=(
1121
+ "Create (or reuse) an isolated git worktree under .kiwi/worktrees/NAME "
1122
+ "and run Kiwi inside it. If NAME is omitted, Kiwi generates one."
1123
+ ),
1124
+ )
1125
+
1112
1126
  parser.add_argument("--action-id", default=None, help="Start a fresh conversation for a specific action.")
1113
1127
  parser.add_argument("--run-id", default=None, help="Continue an existing run.")
1114
1128
  parser.add_argument(
@@ -1126,6 +1140,29 @@ def main() -> int:
1126
1140
 
1127
1141
  args = parser.parse_args(argv)
1128
1142
 
1143
+ if getattr(args, "worktree", None) is not None:
1144
+ try:
1145
+ from kiwi_tui.worktrees import WorktreeError, ensure_worktree
1146
+
1147
+ worktree_name = str(getattr(args, "worktree", "") or "").strip() or None
1148
+ worktree_path, _branch = ensure_worktree(worktree_name)
1149
+ if not worktree_path.exists():
1150
+ raise WorktreeError(
1151
+ f"Worktree directory does not exist after creation: {worktree_path}",
1152
+ hint="Run `git worktree prune` and retry, or choose a different worktree name.",
1153
+ )
1154
+ os.chdir(str(worktree_path))
1155
+ except WorktreeError as e:
1156
+ print(f"Error: {e}", file=sys.stderr)
1157
+ return 1
1158
+ except Exception as e:
1159
+ print(
1160
+ f"Error: Unexpected error while creating/entering the worktree: {e}",
1161
+ file=sys.stderr,
1162
+ )
1163
+ return 1
1164
+
1165
+
1129
1166
  runtime_args = _build_runtime_args(args)
1130
1167
  if args.terminal:
1131
1168
  return run_terminal_mode(
@@ -0,0 +1,315 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ from wonderwords import RandomWord
11
+
12
+
13
+ class WorktreeError(RuntimeError):
14
+ """User-facing error for `kiwi --worktree` failures.
15
+
16
+ Note: do NOT use a frozen dataclass here — Python sets exception traceback
17
+ attributes during propagation, and frozen dataclasses can break that.
18
+ """
19
+
20
+ def __init__(self, message: str, *, details: str | None = None, hint: str | None = None):
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.details = details
24
+ self.hint = hint
25
+
26
+ def __str__(self) -> str: # pragma: no cover (formatting is stable)
27
+ parts: list[str] = [str(self.message or "").strip()]
28
+ if self.details:
29
+ parts.append(str(self.details).strip())
30
+ if self.hint:
31
+ parts.append(f"Hint: {str(self.hint).strip()}")
32
+ return "\n".join(part for part in parts if part)
33
+
34
+ def generate_worktree_name() -> str:
35
+ """Generate a worktree name similar to Claude Code (adj-verb-noun)."""
36
+
37
+ try:
38
+ r = RandomWord()
39
+ adjective = r.word(include_parts_of_speech=["adjectives"]) or "bright"
40
+ verb = r.word(include_parts_of_speech=["verbs"]) or "running"
41
+ noun = r.word(include_parts_of_speech=["nouns"]) or "fox"
42
+ return f"{adjective}-{verb}-{noun}".lower().replace(" ", "-")
43
+ except Exception:
44
+ # Safety fallback: no external data / unexpected packaging issues.
45
+ return "bright-running-fox"
46
+
47
+
48
+ def ensure_worktree(name: str | None, *, cwd: Path | None = None) -> tuple[Path, str]:
49
+ """Ensure a worktree exists under <repo>/.kiwi/worktrees/<name>.
50
+
51
+ Returns (worktree_path, branch_name).
52
+
53
+ Behavior mirrors Claude Code's `--worktree` defaults as closely as possible,
54
+ except we use the provided NAME as the branch name directly:
55
+ - Worktree directory: .kiwi/worktrees/<name>/ at repo root
56
+ - Branch name: <name>
57
+ - Base ref: origin/HEAD if `git fetch origin` succeeds, else local HEAD
58
+ """
59
+
60
+ if not shutil.which("git"):
61
+ raise WorktreeError(
62
+ "Unable to create worktree because `git` was not found on PATH.",
63
+ hint="Install Git and ensure the `git` command is available.",
64
+ )
65
+
66
+ start_cwd = Path(cwd or os.getcwd())
67
+ root_proc = _run_git(["rev-parse", "--show-toplevel"], cwd=start_cwd, check=False)
68
+ if root_proc.returncode != 0:
69
+ stderr = (root_proc.stderr or "").strip()
70
+ raise WorktreeError(
71
+ "Worktree mode requires running inside a git repository.",
72
+ details=stderr or None,
73
+ hint="Run `kiwi -w <name>` from within a git checkout.",
74
+ )
75
+ repo_root = (root_proc.stdout or "").strip()
76
+ if not repo_root:
77
+ raise WorktreeError("Unable to locate the git repository root.")
78
+
79
+ repo_root_path = Path(repo_root)
80
+ worktrees_root = repo_root_path / ".kiwi" / "worktrees"
81
+ try:
82
+ worktrees_root.mkdir(parents=True, exist_ok=True)
83
+ except Exception as e:
84
+ raise WorktreeError(
85
+ f"Unable to create worktree root directory at {worktrees_root}.",
86
+ details=str(e),
87
+ )
88
+
89
+ if not name:
90
+ name = generate_worktree_name()
91
+
92
+ worktree_path = (worktrees_root / name).resolve()
93
+ _assert_within_dir(worktree_path, worktrees_root.resolve())
94
+
95
+ # If already registered as a git worktree, re-use.
96
+ # (Guard against stale worktree metadata: the path can exist in `git worktree list`
97
+ # even if the directory was deleted manually.)
98
+ existing = _list_worktree_paths(repo_root_path)
99
+ existing = _list_worktree_paths(repo_root_path)
100
+ if any(p.resolve() == worktree_path for p in existing):
101
+ if worktree_path.exists():
102
+ if not worktree_path.is_dir():
103
+ raise WorktreeError(
104
+ f"Worktree path exists but is not a directory: {worktree_path}",
105
+ )
106
+ current_branch = _current_branch(worktree_path)
107
+ if current_branch != name:
108
+ pretty_current = current_branch if current_branch != "HEAD" else "(detached HEAD)"
109
+ raise WorktreeError(
110
+ f"Worktree folder {worktree_path} is currently on branch {pretty_current}, expected {name}.",
111
+ hint="Switch the worktree back to the expected branch, or choose a different -w name.",
112
+ )
113
+ return worktree_path, name
114
+ # Worktree is registered but missing on disk. Prune and retry once.
115
+ _run_git(["worktree", "prune"], cwd=repo_root_path, check=False)
116
+ existing = _list_worktree_paths(repo_root_path)
117
+ if any(p.resolve() == worktree_path for p in existing):
118
+ raise WorktreeError(
119
+ f"Worktree is registered with git but missing on disk: {worktree_path}",
120
+ hint="Run `git worktree prune` (or `git worktree remove <path>`) then re-run kiwi -w.",
121
+ )
122
+ if worktree_path.exists():
123
+ raise WorktreeError(
124
+ f"Worktree directory already exists but is not registered with git: {worktree_path}",
125
+ hint="Remove the directory or choose a different worktree name.",
126
+ )
127
+
128
+ # Claude Code branches from origin/HEAD if fetch succeeds.
129
+ base_ref = "HEAD"
130
+ fetch = _run_git(["fetch", "origin"], cwd=repo_root_path, check=False)
131
+ if fetch.returncode == 0:
132
+ verify = _run_git(["rev-parse", "--verify", "origin/HEAD"], cwd=repo_root_path, check=False)
133
+ if verify.returncode == 0:
134
+ base_ref = "origin/HEAD"
135
+
136
+ # Ensure we have at least one commit.
137
+ head_verify = _run_git(["rev-parse", "--verify", "HEAD"], cwd=repo_root_path, check=False)
138
+ if head_verify.returncode != 0:
139
+ raise WorktreeError(
140
+ "This repository has no commits yet, so a worktree cannot be created.",
141
+ hint="Create an initial commit (e.g. add a README and commit) and try again.",
142
+ )
143
+
144
+ branch = name
145
+ branch_exists = (
146
+ _run_git(["show-ref", "--verify", f"refs/heads/{branch}"], cwd=repo_root_path, check=False).returncode
147
+ == 0
148
+ )
149
+
150
+ add_cmd: list[str]
151
+ if branch_exists:
152
+ add_cmd = ["worktree", "add", str(worktree_path), branch]
153
+ else:
154
+ # If a remote branch exists (common case), create a local tracking branch from it.
155
+ remote_ref = f"refs/remotes/origin/{branch}"
156
+ remote_exists = (
157
+ _run_git(["show-ref", "--verify", remote_ref], cwd=repo_root_path, check=False).returncode == 0
158
+ )
159
+ if remote_exists:
160
+ add_cmd = [
161
+ "worktree",
162
+ "add",
163
+ "--track",
164
+ "-b",
165
+ branch,
166
+ str(worktree_path),
167
+ f"origin/{branch}",
168
+ ]
169
+ else:
170
+ add_cmd = ["worktree", "add", str(worktree_path), "-b", branch, base_ref]
171
+
172
+ add_proc = _run_git(add_cmd, cwd=repo_root_path, check=False)
173
+
174
+ if add_proc.returncode != 0:
175
+ stderr = (add_proc.stderr or "").strip()
176
+ stdout = (add_proc.stdout or "").strip()
177
+
178
+ # Auto-heal stale worktree metadata if git claims the branch is checked out
179
+ # at a missing Kiwi-managed worktree path.
180
+ stale_path = _extract_stale_checked_out_path(stderr) if stderr else None
181
+ if stale_path:
182
+ stale = Path(stale_path)
183
+ kiwi_root = (repo_root_path / ".kiwi" / "worktrees").resolve()
184
+ if not stale.exists():
185
+ try:
186
+ stale_resolved = stale.resolve()
187
+ except Exception:
188
+ stale_resolved = stale
189
+ try:
190
+ stale_resolved.relative_to(kiwi_root)
191
+ is_kiwi_managed = True
192
+ except Exception:
193
+ is_kiwi_managed = False
194
+ if is_kiwi_managed:
195
+ _run_git(["worktree", "prune"], cwd=repo_root_path, check=False)
196
+ add_proc = _run_git(add_cmd, cwd=repo_root_path, check=False)
197
+ if add_proc.returncode == 0:
198
+ return worktree_path, branch
199
+ stderr = (add_proc.stderr or "").strip()
200
+ stdout = (add_proc.stdout or "").strip()
201
+
202
+ details = "\n".join(part for part in [stderr, stdout] if part) or None
203
+ hint = None
204
+ if stderr and ("is already checked out at" in stderr or "used by worktree at" in stderr):
205
+ hint = (
206
+ "That branch is already checked out in another worktree. "
207
+ "Choose a different branch name, or remove the other worktree (git worktree list/remove)."
208
+ )
209
+ raise WorktreeError(
210
+ f"Unable to create the git worktree at {worktree_path}.",
211
+ details=details,
212
+ hint=hint,
213
+ )
214
+
215
+ return worktree_path, branch
216
+
217
+
218
+ def _assert_within_dir(path: Path, root: Path) -> None:
219
+ try:
220
+ path.relative_to(root)
221
+ except Exception:
222
+ raise WorktreeError(
223
+ f"Invalid worktree name/path resolved outside {root}: {path}",
224
+ hint="Avoid using '..' or absolute paths in the worktree name.",
225
+ )
226
+
227
+
228
+
229
+ def _extract_stale_checked_out_path(stderr: str) -> str | None:
230
+ """Extract the worktree path from common git "already checked out" errors."""
231
+ if not stderr:
232
+ return None
233
+ patterns = (
234
+ r"checked out at '([^']+)'",
235
+ r"checked out at \"([^\"]+)\"",
236
+ r"used by worktree at '([^']+)'",
237
+ r"used by worktree at \"([^\"]+)\"",
238
+ r"worktree at '([^']+)'",
239
+ r"worktree at \"([^\"]+)\"",
240
+ )
241
+ for pat in patterns:
242
+ m = re.search(pat, stderr)
243
+ if m:
244
+ return m.group(1)
245
+ return None
246
+
247
+
248
+ def _current_branch(worktree_path: Path) -> str:
249
+ """Return the current branch name for a worktree path.
250
+
251
+ Returns "HEAD" when the worktree is detached.
252
+ """
253
+ proc = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=worktree_path, check=False)
254
+ if proc.returncode != 0:
255
+ stderr = (proc.stderr or "").strip()
256
+ raise WorktreeError(
257
+ f"Unable to determine the current branch for worktree at {worktree_path}.",
258
+ details=stderr or None,
259
+ hint="The worktree may be corrupted. Try `git worktree prune` and retry.",
260
+ )
261
+ branch = (proc.stdout or "").strip()
262
+ if not branch:
263
+ raise WorktreeError(
264
+ f"Unable to determine the current branch for worktree at {worktree_path}.",
265
+ hint="The worktree may be corrupted. Try `git worktree prune` and retry.",
266
+ )
267
+ return branch
268
+
269
+ def _list_worktree_paths(repo_root: Path) -> list[Path]:
270
+ proc = _run_git(["worktree", "list", "--porcelain"], cwd=repo_root, check=True)
271
+ paths: list[Path] = []
272
+ for line in (proc.stdout or "").splitlines():
273
+ line = line.strip()
274
+ if line.startswith("worktree "):
275
+ p = line[len("worktree ") :].strip()
276
+ if p:
277
+ paths.append(Path(p))
278
+ return paths
279
+
280
+
281
+ def _git_stdout(args: Sequence[str], *, cwd: Path) -> str:
282
+ proc = _run_git(list(args), cwd=cwd, check=True)
283
+ return proc.stdout or ""
284
+
285
+
286
+ def _run_git(args: Sequence[str], *, cwd: Path, check: bool) -> subprocess.CompletedProcess[str]:
287
+ cmd = ["git", *args]
288
+ try:
289
+ proc = subprocess.run(
290
+ cmd,
291
+ cwd=str(cwd),
292
+ capture_output=True,
293
+ text=True,
294
+ )
295
+ except FileNotFoundError:
296
+ raise WorktreeError(
297
+ "Unable to run git commands because `git` was not found on PATH.",
298
+ hint="Install Git and ensure the `git` command is available.",
299
+ )
300
+ except Exception as e:
301
+ raise WorktreeError(
302
+ f"Unable to run git command: {' '.join(cmd)}",
303
+ details=str(e),
304
+ )
305
+
306
+ if check and proc.returncode != 0:
307
+ stderr = (proc.stderr or "").strip()
308
+ stdout = (proc.stdout or "").strip()
309
+ details = "\n".join(part for part in [stderr, stdout] if part)
310
+ raise WorktreeError(
311
+ f"Git command failed (exit {proc.returncode}): {' '.join(cmd)}",
312
+ details=details or None,
313
+ )
314
+
315
+ return proc
@@ -51,6 +51,9 @@ def test_kiwi_tui_help_exits_zero(isolated_home: Path) -> None:
51
51
  r = _run("kiwi_tui.main", "--help")
52
52
  assert r.returncode == 0, r.stderr
53
53
  assert "kiwi" in (r.stdout + r.stderr).lower()
54
+ combined = (r.stdout + r.stderr).lower()
55
+ assert "--worktree" in combined
56
+ assert "-w" in combined
54
57
 
55
58
 
56
59
  def test_kiwi_terminal_help_exits_zero(isolated_home: Path) -> None:
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from kiwi_tui.worktrees import WorktreeError, ensure_worktree
12
+
13
+
14
+ def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]:
15
+ env = os.environ.copy()
16
+ env.setdefault("GIT_AUTHOR_NAME", "Kiwi Test")
17
+ env.setdefault("GIT_AUTHOR_EMAIL", "kiwi-test@example.com")
18
+ env.setdefault("GIT_COMMITTER_NAME", "Kiwi Test")
19
+ env.setdefault("GIT_COMMITTER_EMAIL", "kiwi-test@example.com")
20
+ return subprocess.run(
21
+ ["git", *args],
22
+ cwd=str(cwd),
23
+ env=env,
24
+ capture_output=True,
25
+ text=True,
26
+ timeout=30,
27
+ )
28
+
29
+
30
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git is required for worktree tests")
31
+ def test_ensure_worktree_creates_under_dot_kiwi(tmp_path: Path) -> None:
32
+ repo = tmp_path / "repo"
33
+ repo.mkdir()
34
+
35
+ r = _git(repo, "init")
36
+ assert r.returncode == 0, r.stderr
37
+
38
+ (repo / "README.md").write_text("hello\n", encoding="utf-8")
39
+ r = _git(repo, "add", "README.md")
40
+ assert r.returncode == 0, r.stderr
41
+ r = _git(repo, "commit", "-m", "init")
42
+ assert r.returncode == 0, r.stderr
43
+
44
+ worktree_path, branch = ensure_worktree("feature-x", cwd=repo)
45
+ assert branch == "feature-x"
46
+
47
+ expected = (repo / ".kiwi" / "worktrees" / "feature-x").resolve()
48
+ assert worktree_path.resolve() == expected
49
+ assert worktree_path.exists()
50
+
51
+ head = subprocess.check_output(
52
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
53
+ cwd=str(worktree_path),
54
+ text=True,
55
+ ).strip()
56
+ assert head == "feature-x"
57
+
58
+ # Should be registered with git.
59
+ wt_list = subprocess.check_output(
60
+ ["git", "worktree", "list", "--porcelain"],
61
+ cwd=str(repo),
62
+ text=True,
63
+ )
64
+ assert str(expected) in wt_list
65
+
66
+
67
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git is required for worktree tests")
68
+ def test_ensure_worktree_reuses_existing(tmp_path: Path) -> None:
69
+ repo = tmp_path / "repo"
70
+ repo.mkdir()
71
+
72
+ r = _git(repo, "init")
73
+ assert r.returncode == 0, r.stderr
74
+
75
+ (repo / "README.md").write_text("hello\n", encoding="utf-8")
76
+ assert _git(repo, "add", "README.md").returncode == 0
77
+ assert _git(repo, "commit", "-m", "init").returncode == 0
78
+
79
+ p1, _b1 = ensure_worktree("reuse-me", cwd=repo)
80
+ p2, _b2 = ensure_worktree("reuse-me", cwd=repo)
81
+ assert p1.resolve() == p2.resolve()
82
+
83
+
84
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git is required for worktree tests")
85
+ def test_ensure_worktree_generates_name(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
86
+ repo = tmp_path / "repo"
87
+ repo.mkdir()
88
+
89
+ r = _git(repo, "init")
90
+ assert r.returncode == 0, r.stderr
91
+
92
+ (repo / "README.md").write_text("hello\n", encoding="utf-8")
93
+ assert _git(repo, "add", "README.md").returncode == 0
94
+ assert _git(repo, "commit", "-m", "init").returncode == 0
95
+
96
+ monkeypatch.setattr("kiwi_tui.worktrees.generate_worktree_name", lambda: "bright-running-fox")
97
+ worktree_path, branch = ensure_worktree(None, cwd=repo)
98
+
99
+ assert branch == "bright-running-fox"
100
+ expected = (repo / ".kiwi" / "worktrees" / "bright-running-fox").resolve()
101
+ assert worktree_path.resolve() == expected
102
+
103
+
104
+ def test_ensure_worktree_requires_git_repo(tmp_path: Path) -> None:
105
+ # Not a git repo.
106
+ with pytest.raises(WorktreeError) as exc:
107
+ ensure_worktree("x", cwd=tmp_path)
108
+
109
+ # Message should be user-facing.
110
+ assert "git" in str(exc.value).lower() or "repository" in str(exc.value).lower()
111
+
112
+
113
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git is required for worktree tests")
114
+ def test_ensure_worktree_uses_remote_branch_when_local_missing(tmp_path: Path) -> None:
115
+ """If origin/<branch> exists but no local branch, create a tracking branch."""
116
+ remote = tmp_path / "remote.git"
117
+ remote.mkdir()
118
+ r = _git(remote, "init", "--bare")
119
+ assert r.returncode == 0, r.stderr
120
+
121
+ seed = tmp_path / "seed"
122
+ seed.mkdir()
123
+ r = _git(seed, "init", "-b", "main")
124
+ assert r.returncode == 0, r.stderr
125
+ (seed / "README.md").write_text("hello\n", encoding="utf-8")
126
+ assert _git(seed, "add", "README.md").returncode == 0
127
+ assert _git(seed, "commit", "-m", "init").returncode == 0
128
+ assert _git(seed, "remote", "add", "origin", str(remote)).returncode == 0
129
+ assert _git(seed, "push", "-u", "origin", "main").returncode == 0
130
+ # Ensure the bare remote's HEAD points at main so clones have a valid HEAD.
131
+ assert _git(remote, "symbolic-ref", "HEAD", "refs/heads/main").returncode == 0
132
+
133
+
134
+ # Create and push a feature branch, then leave it as remote-only in the clone.
135
+ assert _git(seed, "checkout", "-b", "feature-remote").returncode == 0
136
+ (seed / "feature.txt").write_text("feature\n", encoding="utf-8")
137
+ assert _git(seed, "add", "feature.txt").returncode == 0
138
+ assert _git(seed, "commit", "-m", "feature").returncode == 0
139
+ assert _git(seed, "push", "-u", "origin", "feature-remote").returncode == 0
140
+
141
+ repo = tmp_path / "repo"
142
+ clone = subprocess.run(
143
+ ["git", "clone", str(remote), str(repo)],
144
+ capture_output=True,
145
+ text=True,
146
+ timeout=60,
147
+ )
148
+ assert clone.returncode == 0, clone.stderr
149
+
150
+ # Sanity: local branch should not exist yet.
151
+ assert _git(repo, "show-ref", "--verify", "refs/heads/feature-remote").returncode != 0
152
+ assert _git(repo, "show-ref", "--verify", "refs/remotes/origin/feature-remote").returncode == 0
153
+
154
+ worktree_path, branch = ensure_worktree("feature-remote", cwd=repo)
155
+ assert branch == "feature-remote"
156
+
157
+ head = subprocess.check_output(
158
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
159
+ cwd=str(worktree_path),
160
+ text=True,
161
+ ).strip()
162
+ assert head == "feature-remote"
163
+
164
+ upstream = subprocess.check_output(
165
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
166
+ cwd=str(worktree_path),
167
+ text=True,
168
+ ).strip()
169
+ assert upstream == "origin/feature-remote"
170
+
171
+
172
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git is required for worktree tests")
173
+ def test_ensure_worktree_recovers_from_missing_dir(tmp_path: Path) -> None:
174
+ """If a worktree is registered but its directory was deleted, recover via prune."""
175
+ repo = tmp_path / "repo"
176
+ repo.mkdir()
177
+
178
+ r = _git(repo, "init")
179
+ assert r.returncode == 0, r.stderr
180
+
181
+ (repo / "README.md").write_text("hello\n", encoding="utf-8")
182
+ assert _git(repo, "add", "README.md").returncode == 0
183
+ assert _git(repo, "commit", "-m", "init").returncode == 0
184
+
185
+ worktree_path, _branch = ensure_worktree("stale-branch", cwd=repo)
186
+ assert worktree_path.exists()
187
+
188
+ # Simulate user manually deleting the directory (leaving git's worktree metadata stale).
189
+ shutil.rmtree(worktree_path)
190
+ assert not worktree_path.exists()
191
+
192
+ worktree_path2, _branch2 = ensure_worktree("stale-branch", cwd=repo)
193
+ assert worktree_path2.resolve() == worktree_path.resolve()
194
+ assert worktree_path2.exists()
195
+
196
+
197
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git is required for worktree tests")
198
+ def test_ensure_worktree_errors_if_existing_folder_on_wrong_branch(tmp_path: Path) -> None:
199
+ repo = tmp_path / "repo"
200
+ repo.mkdir()
201
+
202
+ r = _git(repo, "init")
203
+ assert r.returncode == 0, r.stderr
204
+
205
+ (repo / "README.md").write_text("hello\n", encoding="utf-8")
206
+ assert _git(repo, "add", "README.md").returncode == 0
207
+ assert _git(repo, "commit", "-m", "init").returncode == 0
208
+
209
+ # Create both branches without checking them out in the main worktree.
210
+ assert _git(repo, "branch", "branch-a").returncode == 0
211
+ assert _git(repo, "branch", "branch-b").returncode == 0
212
+
213
+ # Create the worktree for branch-a.
214
+ worktree_path, _branch = ensure_worktree("branch-a", cwd=repo)
215
+ assert worktree_path.exists()
216
+
217
+ # Switch the worktree folder to branch-b.
218
+ switched = subprocess.run(
219
+ ["git", "switch", "branch-b"],
220
+ cwd=str(worktree_path),
221
+ capture_output=True,
222
+ text=True,
223
+ timeout=30,
224
+ )
225
+ assert switched.returncode == 0, switched.stderr
226
+
227
+ with pytest.raises(WorktreeError):
228
+ ensure_worktree("branch-a", cwd=repo)
229
+
230
+
231
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git is required for worktree tests")
232
+ def test_ensure_worktree_prunes_stale_checked_out_branch(tmp_path: Path) -> None:
233
+ """If git thinks a branch is checked out in a deleted Kiwi worktree, prune + retry."""
234
+ repo = tmp_path / "repo"
235
+ repo.mkdir()
236
+
237
+ r = _git(repo, "init")
238
+ assert r.returncode == 0, r.stderr
239
+
240
+ (repo / "README.md").write_text("hello\n", encoding="utf-8")
241
+ assert _git(repo, "add", "README.md").returncode == 0
242
+ assert _git(repo, "commit", "-m", "init").returncode == 0
243
+
244
+ # Create both branches without checking them out in the main worktree.
245
+ assert _git(repo, "branch", "branch-a").returncode == 0
246
+ assert _git(repo, "branch", "branch-b").returncode == 0
247
+
248
+ # Create the worktree at .kiwi/worktrees/branch-a and then switch it to branch-b.
249
+ worktree_a, _ = ensure_worktree("branch-a", cwd=repo)
250
+ switched = subprocess.run(
251
+ ["git", "switch", "branch-b"],
252
+ cwd=str(worktree_a),
253
+ capture_output=True,
254
+ text=True,
255
+ timeout=30,
256
+ )
257
+ assert switched.returncode == 0, switched.stderr
258
+
259
+ # Delete the worktree directory without unregistering it (stale git metadata).
260
+ shutil.rmtree(worktree_a)
261
+ assert not worktree_a.exists()
262
+
263
+ worktree_b, branch = ensure_worktree("branch-b", cwd=repo)
264
+ assert branch == "branch-b"
265
+ assert worktree_b.exists()
266
+
267
+ head = subprocess.check_output(
268
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
269
+ cwd=str(worktree_b),
270
+ text=True,
271
+ ).strip()
272
+ assert head == "branch-b"
@@ -397,7 +397,7 @@ wheels = [
397
397
 
398
398
  [[package]]
399
399
  name = "kiwi-code"
400
- version = "0.0.43"
400
+ version = "0.0.431"
401
401
  source = { editable = "." }
402
402
  dependencies = [
403
403
  { name = "autobots-client" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes