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.
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/PKG-INFO +1 -1
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/pyproject.toml +1 -1
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/main.py +37 -0
- kiwi_code-0.0.431/src/kiwi_tui/worktrees.py +315 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_cli_help.py +3 -0
- kiwi_code-0.0.431/tests/test_worktrees.py +272 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/uv.lock +1 -1
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/.gitignore +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/.python-version +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/CLAUDE.md +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/Makefile +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/README.md +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/dashboard.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/test_hello.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/__init__.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/conftest.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_tui_headless.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.43 → kiwi_code-0.0.431}/tests/test_tui_palette.py +0 -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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|