kiwi-code 0.0.42__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.42 → kiwi_code-0.0.431}/PKG-INFO +1 -1
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/pyproject.toml +1 -1
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/main.py +37 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/screens/dashboard.py +1 -0
- kiwi_code-0.0.431/src/kiwi_tui/worktrees.py +315 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/tests/test_cli_help.py +3 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/tests/test_tui_headless.py +253 -27
- kiwi_code-0.0.431/tests/test_worktrees.py +272 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/uv.lock +1 -1
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/.gitignore +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/.python-version +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/CLAUDE.md +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/Makefile +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/README.md +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/slash_commands.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/test_hello.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/tests/__init__.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/tests/conftest.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.42 → kiwi_code-0.0.431}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.42 → 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:
|
|
@@ -701,7 +701,7 @@ async def test_tui_continue_run_updates_action_name_from_run(
|
|
|
701
701
|
async def test_tui_autocode_select_shows_only_special_action_names(
|
|
702
702
|
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
703
703
|
) -> None:
|
|
704
|
-
"""/autocode-select should open the picker with only the
|
|
704
|
+
"""/autocode-select should open the picker with only the 4 special actions."""
|
|
705
705
|
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
706
706
|
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
707
707
|
tokens_path.write_text(
|
|
@@ -720,6 +720,7 @@ async def test_tui_autocode_select_shows_only_special_action_names(
|
|
|
720
720
|
"69c2180355a89324a9926bc6": "AutoCode Alpha",
|
|
721
721
|
"6a0625b8ab5f80ba8ac5d012": "AutoCode Beta",
|
|
722
722
|
"69e4be0d70c2d7a89197e66e": "AutoCode Gamma",
|
|
723
|
+
"6a1c0773c519da8a0fa9b8a4": "AutoCode Delta",
|
|
723
724
|
}
|
|
724
725
|
|
|
725
726
|
from kiwi_tui.screens.dashboard import DashboardScreen
|
|
@@ -753,10 +754,11 @@ async def test_tui_autocode_select_shows_only_special_action_names(
|
|
|
753
754
|
|
|
754
755
|
assert type(app.screen).__name__ == "IdPickerScreen"
|
|
755
756
|
table = app.screen.query_one("#idpicker-table", DataTable)
|
|
756
|
-
assert table.row_count ==
|
|
757
|
+
assert table.row_count == 4
|
|
757
758
|
assert list(table.get_row_at(0)) == ["1", "AutoCode Alpha"]
|
|
758
759
|
assert list(table.get_row_at(1)) == ["2", "AutoCode Beta"]
|
|
759
760
|
assert list(table.get_row_at(2)) == ["3", "AutoCode Gamma"]
|
|
761
|
+
assert list(table.get_row_at(3)) == ["4", "AutoCode Delta"]
|
|
760
762
|
|
|
761
763
|
|
|
762
764
|
|
|
@@ -853,8 +855,6 @@ async def test_tui_streaming_requests_autofollow_when_viewport_is_near_bottom(
|
|
|
853
855
|
)
|
|
854
856
|
await pilot.pause()
|
|
855
857
|
assert calls == [{"after_refresh": True}]
|
|
856
|
-
|
|
857
|
-
|
|
858
858
|
@pytest.mark.asyncio
|
|
859
859
|
async def test_tui_streaming_does_not_force_scroll_when_user_reads_older_messages(
|
|
860
860
|
isolated_home: Path,
|
|
@@ -881,45 +881,271 @@ async def test_tui_streaming_does_not_force_scroll_when_user_reads_older_message
|
|
|
881
881
|
await pilot.pause()
|
|
882
882
|
assert type(app.screen).__name__ == "DashboardScreen"
|
|
883
883
|
screen = app.screen
|
|
884
|
-
messages = screen.query_one("#messages")
|
|
885
884
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
885
|
+
calls: list[dict] = []
|
|
886
|
+
monkeypatch = pytest.MonkeyPatch()
|
|
887
|
+
monkeypatch.setattr(screen, "_messages_is_near_bottom", lambda *args, **kwargs: False)
|
|
888
|
+
monkeypatch.setattr(
|
|
889
|
+
screen,
|
|
890
|
+
"_scroll_messages_to_end",
|
|
891
|
+
lambda *args, **kwargs: calls.append(dict(kwargs)),
|
|
892
|
+
)
|
|
893
|
+
try:
|
|
894
|
+
widget = screen.update_streaming_message({"blocks": [{"text": "stream start"}]})
|
|
895
|
+
await pilot.pause()
|
|
896
|
+
assert widget is not None
|
|
897
|
+
assert calls == []
|
|
898
|
+
|
|
899
|
+
screen.update_streaming_message(
|
|
900
|
+
{"blocks": [{"text": "\n".join(f"stream line {i}" for i in range(60))}]},
|
|
901
|
+
widget,
|
|
890
902
|
)
|
|
903
|
+
await pilot.pause()
|
|
904
|
+
assert calls == []
|
|
905
|
+
finally:
|
|
906
|
+
monkeypatch.undo()
|
|
907
|
+
|
|
908
|
+
@pytest.mark.asyncio
|
|
909
|
+
async def test_tui_quits_cleanly(isolated_home: Path) -> None:
|
|
910
|
+
from kiwi_tui.main import AutobotsTUI
|
|
911
|
+
|
|
912
|
+
app = AutobotsTUI()
|
|
913
|
+
async with app.run_test() as pilot:
|
|
891
914
|
await pilot.pause()
|
|
892
|
-
|
|
915
|
+
await pilot.press("ctrl+c")
|
|
916
|
+
await pilot.pause()
|
|
917
|
+
# Reaching here without an exception is the assertion.
|
|
893
918
|
|
|
894
|
-
|
|
919
|
+
|
|
920
|
+
@pytest.mark.asyncio
|
|
921
|
+
async def test_tui_redirects_to_login_when_auth_expires(isolated_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
922
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
923
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
924
|
+
tokens_path.write_text(
|
|
925
|
+
json.dumps(
|
|
926
|
+
{
|
|
927
|
+
"access_token": "test-access-token",
|
|
928
|
+
"refresh_token": "test-refresh-token",
|
|
929
|
+
"token_type": "Bearer",
|
|
930
|
+
"expires_at": None,
|
|
931
|
+
}
|
|
932
|
+
),
|
|
933
|
+
encoding="utf-8",
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
937
|
+
monkeypatch.setattr(
|
|
938
|
+
get_action_v1_actions_id_get,
|
|
939
|
+
"sync_detailed",
|
|
940
|
+
lambda *, id, client: SimpleNamespace(
|
|
941
|
+
status_code=200,
|
|
942
|
+
parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
|
|
943
|
+
),
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
from kiwi_tui.main import AutobotsTUI
|
|
947
|
+
|
|
948
|
+
app = AutobotsTUI()
|
|
949
|
+
async with app.run_test() as pilot:
|
|
895
950
|
await pilot.pause()
|
|
896
|
-
assert
|
|
951
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
897
952
|
|
|
898
|
-
|
|
899
|
-
messages.scroll_to(y=target_y, animate=False, immediate=True)
|
|
953
|
+
app._handle_auth_expired("Session expired during test.")
|
|
900
954
|
await pilot.pause()
|
|
901
|
-
y_before = int(messages.scroll_offset.y)
|
|
902
|
-
target_before = int(messages.scroll_target_y)
|
|
903
|
-
assert y_before == target_before
|
|
904
|
-
assert y_before < int(messages.max_scroll_y)
|
|
905
955
|
|
|
906
|
-
screen.
|
|
907
|
-
|
|
908
|
-
|
|
956
|
+
assert type(app.screen).__name__ == "LoginScreen"
|
|
957
|
+
assert not tokens_path.exists()
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
@pytest.mark.asyncio
|
|
961
|
+
async def test_tui_run_action_403_redirects_to_login_when_refresh_cannot_run(
|
|
962
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
963
|
+
) -> None:
|
|
964
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
965
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
966
|
+
tokens_path.write_text(
|
|
967
|
+
json.dumps(
|
|
968
|
+
{
|
|
969
|
+
"access_token": "test-access-token",
|
|
970
|
+
"refresh_token": "test-refresh-token",
|
|
971
|
+
"token_type": "Bearer",
|
|
972
|
+
"expires_at": None,
|
|
973
|
+
}
|
|
974
|
+
),
|
|
975
|
+
encoding="utf-8",
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
979
|
+
monkeypatch.setattr(
|
|
980
|
+
get_action_v1_actions_id_get,
|
|
981
|
+
"sync_detailed",
|
|
982
|
+
lambda *, id, client: SimpleNamespace(
|
|
983
|
+
status_code=200,
|
|
984
|
+
parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
|
|
985
|
+
),
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
from kiwi_tui.main import AutobotsTUI
|
|
989
|
+
|
|
990
|
+
app = AutobotsTUI()
|
|
991
|
+
async with app.run_test() as pilot:
|
|
992
|
+
await pilot.pause()
|
|
993
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
994
|
+
screen = app.screen
|
|
995
|
+
|
|
996
|
+
monkeypatch.setattr(app, "_refresh_token_if_needed", lambda force=False: False)
|
|
997
|
+
monkeypatch.setattr(
|
|
998
|
+
app.autobots_client,
|
|
999
|
+
"run_action_async",
|
|
1000
|
+
lambda *args, **kwargs: (False, None, "Failed to start action: status 403"),
|
|
909
1001
|
)
|
|
1002
|
+
|
|
1003
|
+
screen.run_action_with_polling("hello")
|
|
1004
|
+
await pilot.pause(0.3)
|
|
1005
|
+
await pilot.pause(0.3)
|
|
1006
|
+
|
|
1007
|
+
assert type(app.screen).__name__ == "LoginScreen"
|
|
1008
|
+
assert not tokens_path.exists()
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
@pytest.mark.asyncio
|
|
1012
|
+
async def test_tui_run_action_403_redirects_to_login_on_second_failure(
|
|
1013
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
1014
|
+
) -> None:
|
|
1015
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
1016
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1017
|
+
tokens_path.write_text(
|
|
1018
|
+
json.dumps(
|
|
1019
|
+
{
|
|
1020
|
+
"access_token": "test-access-token",
|
|
1021
|
+
"refresh_token": "test-refresh-token",
|
|
1022
|
+
"token_type": "Bearer",
|
|
1023
|
+
"expires_at": None,
|
|
1024
|
+
}
|
|
1025
|
+
),
|
|
1026
|
+
encoding="utf-8",
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
1030
|
+
monkeypatch.setattr(
|
|
1031
|
+
get_action_v1_actions_id_get,
|
|
1032
|
+
"sync_detailed",
|
|
1033
|
+
lambda *, id, client: SimpleNamespace(
|
|
1034
|
+
status_code=200,
|
|
1035
|
+
parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
|
|
1036
|
+
),
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
from kiwi_tui.main import AutobotsTUI
|
|
1040
|
+
|
|
1041
|
+
app = AutobotsTUI()
|
|
1042
|
+
async with app.run_test() as pilot:
|
|
910
1043
|
await pilot.pause()
|
|
1044
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
1045
|
+
screen = app.screen
|
|
1046
|
+
|
|
1047
|
+
monkeypatch.setattr(app, "_refresh_token_if_needed", lambda force=False: True)
|
|
1048
|
+
monkeypatch.setattr(
|
|
1049
|
+
app.autobots_client,
|
|
1050
|
+
"run_action_async",
|
|
1051
|
+
lambda *args, **kwargs: (False, None, "Failed to start action: status 403"),
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
screen.run_action_with_polling("hello")
|
|
1055
|
+
await pilot.pause(0.3)
|
|
1056
|
+
await pilot.pause(0.3)
|
|
1057
|
+
|
|
1058
|
+
assert type(app.screen).__name__ == "LoginScreen"
|
|
1059
|
+
assert not tokens_path.exists()
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def test_tui_force_refresh_uses_refresh_token_even_when_access_token_not_expired(
|
|
1063
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
1064
|
+
) -> None:
|
|
1065
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
1066
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1067
|
+
tokens_path.write_text(
|
|
1068
|
+
json.dumps(
|
|
1069
|
+
{
|
|
1070
|
+
"access_token": "test-access-token",
|
|
1071
|
+
"refresh_token": "test-refresh-token",
|
|
1072
|
+
"token_type": "Bearer",
|
|
1073
|
+
"expires_at": "2999-01-01T00:00:00",
|
|
1074
|
+
}
|
|
1075
|
+
),
|
|
1076
|
+
encoding="utf-8",
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
from kiwi_cli.models import AuthTokens
|
|
1080
|
+
from kiwi_tui.main import AutobotsTUI
|
|
1081
|
+
|
|
1082
|
+
app = AutobotsTUI()
|
|
1083
|
+
|
|
1084
|
+
calls: list[str] = []
|
|
1085
|
+
|
|
1086
|
+
def _fake_refresh(refresh_token: str):
|
|
1087
|
+
calls.append(refresh_token)
|
|
1088
|
+
return True, AuthTokens(
|
|
1089
|
+
access_token="new-access-token",
|
|
1090
|
+
refresh_token="new-refresh-token",
|
|
1091
|
+
token_type="Bearer",
|
|
1092
|
+
expires_at=None,
|
|
1093
|
+
), "ok"
|
|
1094
|
+
|
|
1095
|
+
monkeypatch.setattr(app.autobots_client, "refresh_token", _fake_refresh)
|
|
1096
|
+
|
|
1097
|
+
assert app._refresh_token_if_needed(force=True) is True
|
|
1098
|
+
assert calls == ["test-refresh-token"]
|
|
911
1099
|
|
|
912
|
-
assert int(messages.scroll_offset.y) == y_before
|
|
913
|
-
assert int(messages.scroll_target_y) == target_before
|
|
914
|
-
assert int(messages.scroll_offset.y) < int(messages.max_scroll_y)
|
|
915
1100
|
|
|
916
1101
|
@pytest.mark.asyncio
|
|
917
|
-
async def
|
|
1102
|
+
async def test_tui_auth_expired_clears_dashboard_draft_and_messages(
|
|
1103
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch
|
|
1104
|
+
) -> None:
|
|
1105
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
1106
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1107
|
+
tokens_path.write_text(
|
|
1108
|
+
json.dumps(
|
|
1109
|
+
{
|
|
1110
|
+
"access_token": "test-access-token",
|
|
1111
|
+
"refresh_token": "test-refresh-token",
|
|
1112
|
+
"token_type": "Bearer",
|
|
1113
|
+
"expires_at": None,
|
|
1114
|
+
}
|
|
1115
|
+
),
|
|
1116
|
+
encoding="utf-8",
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
from autobots_client.api.actions import get_action_v1_actions_id_get
|
|
1120
|
+
monkeypatch.setattr(
|
|
1121
|
+
get_action_v1_actions_id_get,
|
|
1122
|
+
"sync_detailed",
|
|
1123
|
+
lambda *, id, client: SimpleNamespace(
|
|
1124
|
+
status_code=200,
|
|
1125
|
+
parsed=SimpleNamespace(field_id=id, name="AutoCode Default"),
|
|
1126
|
+
),
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
from textual.containers import VerticalScroll
|
|
918
1130
|
from kiwi_tui.main import AutobotsTUI
|
|
1131
|
+
from kiwi_tui.widgets import ChatInput
|
|
919
1132
|
|
|
920
1133
|
app = AutobotsTUI()
|
|
921
1134
|
async with app.run_test() as pilot:
|
|
922
1135
|
await pilot.pause()
|
|
923
|
-
|
|
1136
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
1137
|
+
dashboard = app.screen
|
|
1138
|
+
chat_input = dashboard.query_one("#chat-input", ChatInput)
|
|
1139
|
+
messages = dashboard.query_one("#messages", VerticalScroll)
|
|
1140
|
+
|
|
1141
|
+
chat_input.value = "hi"
|
|
1142
|
+
dashboard.add_message("hi", "user")
|
|
924
1143
|
await pilot.pause()
|
|
925
|
-
|
|
1144
|
+
assert len(list(messages.children)) > 0
|
|
1145
|
+
|
|
1146
|
+
app._handle_auth_expired("Session expired during test.")
|
|
1147
|
+
await pilot.pause()
|
|
1148
|
+
|
|
1149
|
+
assert chat_input.value == ""
|
|
1150
|
+
assert len(list(messages.children)) == 0
|
|
1151
|
+
assert type(app.screen).__name__ == "LoginScreen"
|
|
@@ -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
|