forestui 0.9.9__tar.gz → 1.0.0__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.
- {forestui-0.9.9 → forestui-1.0.0}/PKG-INFO +1 -1
- {forestui-0.9.9 → forestui-1.0.0}/forestui/app.py +71 -4
- {forestui-0.9.9 → forestui-1.0.0}/forestui/components/modals.py +159 -16
- {forestui-0.9.9 → forestui-1.0.0}/forestui/components/worktree_detail.py +6 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/models.py +4 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/services/git.py +107 -14
- {forestui-0.9.9 → forestui-1.0.0}/forestui/theme.py +17 -2
- {forestui-0.9.9 → forestui-1.0.0}/pyproject.toml +1 -1
- {forestui-0.9.9 → forestui-1.0.0}/.github/workflows/publish.yml +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/.gitignore +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/.pre-commit-config.yaml +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/.python-version +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/CLAUDE.md +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/Makefile +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/README.md +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/doc/screenshot_small.png +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/__init__.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/__main__.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/cli.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/components/__init__.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/components/messages.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/components/repository_detail.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/components/sidebar.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/services/__init__.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/services/claude_session.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/services/github.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/services/settings.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/services/tmux.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/forestui/state.py +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/install.sh +0 -0
- {forestui-0.9.9 → forestui-1.0.0}/uv.lock +0 -0
|
@@ -456,12 +456,21 @@ class ForestApp(App[None]):
|
|
|
456
456
|
return
|
|
457
457
|
try:
|
|
458
458
|
branches = await self._git_service.list_branches(repo.source_path)
|
|
459
|
+
current_branch = await self._git_service.get_current_branch(
|
|
460
|
+
repo.source_path
|
|
461
|
+
)
|
|
459
462
|
except GitError:
|
|
460
463
|
branches = []
|
|
464
|
+
current_branch = "main"
|
|
461
465
|
settings = self._settings_service.settings
|
|
462
466
|
self.push_screen(
|
|
463
467
|
CreateWorktreeFromIssueModal(
|
|
464
|
-
repo,
|
|
468
|
+
repo,
|
|
469
|
+
issue,
|
|
470
|
+
branches,
|
|
471
|
+
get_forest_path(),
|
|
472
|
+
settings.branch_prefix,
|
|
473
|
+
current_branch,
|
|
465
474
|
)
|
|
466
475
|
)
|
|
467
476
|
|
|
@@ -483,11 +492,26 @@ class ForestApp(App[None]):
|
|
|
483
492
|
self.notify("Pulling repo...")
|
|
484
493
|
await self._git_service.pull(repo.source_path)
|
|
485
494
|
|
|
495
|
+
# Get the ref for the base branch before creating
|
|
496
|
+
base_ref: str | None = None
|
|
497
|
+
if event.base_branch:
|
|
498
|
+
base_ref = await self._git_service.get_ref(
|
|
499
|
+
repo.source_path, event.base_branch
|
|
500
|
+
)
|
|
501
|
+
|
|
486
502
|
await self._git_service.create_worktree(
|
|
487
|
-
repo.source_path,
|
|
503
|
+
repo.source_path,
|
|
504
|
+
worktree_path,
|
|
505
|
+
event.branch,
|
|
506
|
+
event.new_branch,
|
|
507
|
+
event.base_branch,
|
|
488
508
|
)
|
|
489
509
|
worktree = Worktree(
|
|
490
|
-
name=event.name,
|
|
510
|
+
name=event.name,
|
|
511
|
+
branch=event.branch,
|
|
512
|
+
path=str(worktree_path),
|
|
513
|
+
base_branch=event.base_branch,
|
|
514
|
+
created_from_ref=base_ref,
|
|
491
515
|
)
|
|
492
516
|
self._state.add_worktree(event.repo_id, worktree)
|
|
493
517
|
self._state.select_worktree(event.repo_id, worktree.id)
|
|
@@ -497,6 +521,31 @@ class ForestApp(App[None]):
|
|
|
497
521
|
except GitError as e:
|
|
498
522
|
self.notify(f"Failed to create worktree: {e}", severity="error")
|
|
499
523
|
|
|
524
|
+
@work
|
|
525
|
+
async def on_create_worktree_from_issue_modal_fetch_requested(
|
|
526
|
+
self, event: CreateWorktreeFromIssueModal.FetchRequested
|
|
527
|
+
) -> None:
|
|
528
|
+
"""Handle fetch request from the create worktree modal."""
|
|
529
|
+
branches: list[str] = []
|
|
530
|
+
error: str | None = None
|
|
531
|
+
try:
|
|
532
|
+
await self._git_service.fetch(event.repo_path)
|
|
533
|
+
branches = await self._git_service.list_branches(event.repo_path)
|
|
534
|
+
except GitError as e:
|
|
535
|
+
error = str(e)
|
|
536
|
+
|
|
537
|
+
# Update the modal - use screen stack to find it
|
|
538
|
+
for screen in self.screen_stack:
|
|
539
|
+
if isinstance(screen, CreateWorktreeFromIssueModal):
|
|
540
|
+
if error:
|
|
541
|
+
screen.fetch_failed(error)
|
|
542
|
+
else:
|
|
543
|
+
screen.update_branches(branches)
|
|
544
|
+
break
|
|
545
|
+
|
|
546
|
+
if error:
|
|
547
|
+
self.notify(f"Fetch failed: {error}", severity="error")
|
|
548
|
+
|
|
500
549
|
async def on_worktree_detail_archive_worktree_requested(
|
|
501
550
|
self, event: WorktreeDetail.ArchiveWorktreeRequested
|
|
502
551
|
) -> None:
|
|
@@ -610,11 +659,29 @@ class ForestApp(App[None]):
|
|
|
610
659
|
worktree_path = forest_dir / repo.name / event.name
|
|
611
660
|
|
|
612
661
|
try:
|
|
662
|
+
# Determine base branch and get ref before creating
|
|
663
|
+
if event.new_branch:
|
|
664
|
+
# New branch stems from HEAD
|
|
665
|
+
base_branch = await self._git_service.get_current_branch(
|
|
666
|
+
repo.source_path
|
|
667
|
+
)
|
|
668
|
+
base_ref = await self._git_service.get_ref(repo.source_path, "HEAD")
|
|
669
|
+
else:
|
|
670
|
+
# Existing branch - base is the branch itself
|
|
671
|
+
base_branch = event.branch
|
|
672
|
+
base_ref = await self._git_service.get_ref(
|
|
673
|
+
repo.source_path, event.branch
|
|
674
|
+
)
|
|
675
|
+
|
|
613
676
|
await self._git_service.create_worktree(
|
|
614
677
|
repo.source_path, worktree_path, event.branch, event.new_branch
|
|
615
678
|
)
|
|
616
679
|
worktree = Worktree(
|
|
617
|
-
name=event.name,
|
|
680
|
+
name=event.name,
|
|
681
|
+
branch=event.branch,
|
|
682
|
+
path=str(worktree_path),
|
|
683
|
+
base_branch=base_branch,
|
|
684
|
+
created_from_ref=base_ref,
|
|
618
685
|
)
|
|
619
686
|
self._state.add_worktree(event.repo_id, worktree)
|
|
620
687
|
self._state.select_worktree(event.repo_id, worktree.id)
|
|
@@ -7,6 +7,8 @@ from textual.app import ComposeResult
|
|
|
7
7
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
8
8
|
from textual.message import Message
|
|
9
9
|
from textual.screen import ModalScreen
|
|
10
|
+
from textual.suggester import SuggestFromList
|
|
11
|
+
from textual.timer import Timer
|
|
10
12
|
from textual.widgets import Button, Checkbox, Input, Label, Select
|
|
11
13
|
|
|
12
14
|
from forestui.models import (
|
|
@@ -203,10 +205,10 @@ class AddWorktreeModal(ModalScreen[tuple[str, str, bool] | None]):
|
|
|
203
205
|
id="input-branch",
|
|
204
206
|
)
|
|
205
207
|
|
|
206
|
-
yield
|
|
207
|
-
|
|
208
|
-
id="
|
|
209
|
-
|
|
208
|
+
yield Input(
|
|
209
|
+
placeholder="Start typing to search branches...",
|
|
210
|
+
id="input-existing-branch",
|
|
211
|
+
suggester=SuggestFromList(self._branches, case_sensitive=False),
|
|
210
212
|
)
|
|
211
213
|
|
|
212
214
|
yield Label("", id="label-error", classes="label-destructive")
|
|
@@ -217,11 +219,14 @@ class AddWorktreeModal(ModalScreen[tuple[str, str, bool] | None]):
|
|
|
217
219
|
|
|
218
220
|
def on_mount(self) -> None:
|
|
219
221
|
"""Set up initial state."""
|
|
220
|
-
# Hide the
|
|
221
|
-
self.query_one("#
|
|
222
|
+
# Hide the existing branch input by default (new branch mode)
|
|
223
|
+
self.query_one("#input-existing-branch", Input).display = False
|
|
222
224
|
|
|
223
225
|
def on_input_changed(self, event: Input.Changed) -> None:
|
|
224
226
|
"""Handle input changes."""
|
|
227
|
+
# Clear any error when user types
|
|
228
|
+
self._clear_error()
|
|
229
|
+
|
|
225
230
|
if event.input.id == "input-name":
|
|
226
231
|
self._name = self._sanitize_name(event.value)
|
|
227
232
|
self._update_path_preview()
|
|
@@ -230,13 +235,23 @@ class AddWorktreeModal(ModalScreen[tuple[str, str, bool] | None]):
|
|
|
230
235
|
branch_input = self.query_one("#input-branch", Input)
|
|
231
236
|
branch_input.value = f"{self._branch_prefix}{self._name}"
|
|
232
237
|
self._branch = branch_input.value
|
|
233
|
-
elif event.input.id
|
|
238
|
+
elif event.input.id in ("input-branch", "input-existing-branch"):
|
|
234
239
|
self._branch = event.value
|
|
235
240
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
241
|
+
self._update_create_button_state()
|
|
242
|
+
|
|
243
|
+
def _clear_error(self) -> None:
|
|
244
|
+
"""Clear the error label."""
|
|
245
|
+
self.query_one("#label-error", Label).update("")
|
|
246
|
+
|
|
247
|
+
def _update_create_button_state(self) -> None:
|
|
248
|
+
"""Enable/disable Create button based on validation."""
|
|
249
|
+
btn = self.query_one("#btn-create", Button)
|
|
250
|
+
# For existing branch mode, branch must be in the list
|
|
251
|
+
if not self._new_branch and self._branch not in self._branches:
|
|
252
|
+
btn.disabled = True
|
|
253
|
+
else:
|
|
254
|
+
btn.disabled = False
|
|
240
255
|
|
|
241
256
|
def _sanitize_name(self, name: str) -> str:
|
|
242
257
|
"""Sanitize worktree name to valid characters."""
|
|
@@ -271,29 +286,36 @@ class AddWorktreeModal(ModalScreen[tuple[str, str, bool] | None]):
|
|
|
271
286
|
new_btn = self.query_one("#btn-new-branch", Button)
|
|
272
287
|
existing_btn = self.query_one("#btn-existing-branch", Button)
|
|
273
288
|
branch_input = self.query_one("#input-branch", Input)
|
|
274
|
-
|
|
289
|
+
existing_branch_input = self.query_one("#input-existing-branch", Input)
|
|
275
290
|
|
|
276
291
|
if new_branch:
|
|
277
292
|
new_btn.variant = "primary"
|
|
278
293
|
existing_btn.variant = "default"
|
|
279
294
|
branch_input.display = True
|
|
280
|
-
|
|
295
|
+
existing_branch_input.display = False
|
|
281
296
|
else:
|
|
282
297
|
new_btn.variant = "default"
|
|
283
298
|
existing_btn.variant = "primary"
|
|
284
299
|
branch_input.display = False
|
|
285
|
-
|
|
300
|
+
existing_branch_input.display = True
|
|
301
|
+
|
|
302
|
+
self._update_create_button_state()
|
|
286
303
|
|
|
287
304
|
def _create_worktree(self) -> None:
|
|
288
305
|
"""Create the worktree if valid."""
|
|
289
306
|
error_label = self.query_one("#label-error", Label)
|
|
290
307
|
|
|
291
308
|
if not self._name:
|
|
292
|
-
error_label.update("
|
|
309
|
+
error_label.update(" Worktree name is required")
|
|
293
310
|
return
|
|
294
311
|
|
|
295
312
|
if not self._branch:
|
|
296
|
-
error_label.update(" Branch is required")
|
|
313
|
+
error_label.update(" Branch name is required")
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
# For existing branch mode, validate the branch exists
|
|
317
|
+
if not self._new_branch and self._branch not in self._branches:
|
|
318
|
+
error_label.update(f" Branch '{self._branch}' does not exist")
|
|
297
319
|
return
|
|
298
320
|
|
|
299
321
|
# Check if worktree path already exists
|
|
@@ -484,12 +506,21 @@ class CreateWorktreeFromIssueModal(ModalScreen[tuple[str, str, bool, bool] | Non
|
|
|
484
506
|
branch: str,
|
|
485
507
|
new_branch: bool,
|
|
486
508
|
pull_first: bool,
|
|
509
|
+
base_branch: str | None = None,
|
|
487
510
|
) -> None:
|
|
488
511
|
self.repo_id = repo_id
|
|
489
512
|
self.name = name
|
|
490
513
|
self.branch = branch
|
|
491
514
|
self.new_branch = new_branch
|
|
492
515
|
self.pull_first = pull_first
|
|
516
|
+
self.base_branch = base_branch
|
|
517
|
+
super().__init__()
|
|
518
|
+
|
|
519
|
+
class FetchRequested(Message):
|
|
520
|
+
"""Request to fetch from remote."""
|
|
521
|
+
|
|
522
|
+
def __init__(self, repo_path: str) -> None:
|
|
523
|
+
self.repo_path = repo_path
|
|
493
524
|
super().__init__()
|
|
494
525
|
|
|
495
526
|
def __init__(
|
|
@@ -499,6 +530,7 @@ class CreateWorktreeFromIssueModal(ModalScreen[tuple[str, str, bool, bool] | Non
|
|
|
499
530
|
branches: list[str],
|
|
500
531
|
forest_dir: Path,
|
|
501
532
|
branch_prefix: str = "feat/",
|
|
533
|
+
current_branch: str = "main",
|
|
502
534
|
) -> None:
|
|
503
535
|
super().__init__()
|
|
504
536
|
self._repository = repository
|
|
@@ -506,10 +538,34 @@ class CreateWorktreeFromIssueModal(ModalScreen[tuple[str, str, bool, bool] | Non
|
|
|
506
538
|
self._branches = branches
|
|
507
539
|
self._forest_dir = forest_dir
|
|
508
540
|
self._branch_prefix = branch_prefix
|
|
541
|
+
self._current_branch = current_branch
|
|
509
542
|
# Pre-fill from issue
|
|
510
543
|
self._name: str = issue.branch_name
|
|
511
544
|
self._branch: str = f"{branch_prefix}{issue.branch_name}"
|
|
512
545
|
self._pull_first: bool = True
|
|
546
|
+
# Default base branch: prefer origin/<current> if available
|
|
547
|
+
self._base_branch: str = self._compute_default_base_branch()
|
|
548
|
+
self._is_fetching: bool = False
|
|
549
|
+
# Spinner animation
|
|
550
|
+
self._spinner_chars = "|/-\\"
|
|
551
|
+
self._spinner_index = 0
|
|
552
|
+
self._spinner_timer: Timer | None = None
|
|
553
|
+
|
|
554
|
+
def _compute_default_base_branch(self) -> str:
|
|
555
|
+
"""Compute the default base branch, preferring remote version."""
|
|
556
|
+
# Look for origin/<current_branch> first
|
|
557
|
+
remote_branch = f"origin/{self._current_branch}"
|
|
558
|
+
if remote_branch in self._branches:
|
|
559
|
+
return remote_branch
|
|
560
|
+
# Try upstream/<current_branch>
|
|
561
|
+
upstream_branch = f"upstream/{self._current_branch}"
|
|
562
|
+
if upstream_branch in self._branches:
|
|
563
|
+
return upstream_branch
|
|
564
|
+
# Fall back to local current branch
|
|
565
|
+
if self._current_branch in self._branches:
|
|
566
|
+
return self._current_branch
|
|
567
|
+
# Last resort: first branch in list or empty
|
|
568
|
+
return self._branches[0] if self._branches else ""
|
|
513
569
|
|
|
514
570
|
def compose(self) -> ComposeResult:
|
|
515
571
|
"""Compose the modal UI."""
|
|
@@ -533,6 +589,16 @@ class CreateWorktreeFromIssueModal(ModalScreen[tuple[str, str, bool, bool] | Non
|
|
|
533
589
|
value=self._branch, id="input-branch", placeholder="feat/branch-name"
|
|
534
590
|
)
|
|
535
591
|
|
|
592
|
+
yield Label("Base Branch", classes="section-header")
|
|
593
|
+
with Horizontal(classes="base-branch-row"):
|
|
594
|
+
yield Input(
|
|
595
|
+
value=self._base_branch,
|
|
596
|
+
id="input-base-branch",
|
|
597
|
+
placeholder="origin/main",
|
|
598
|
+
suggester=SuggestFromList(self._branches, case_sensitive=False),
|
|
599
|
+
)
|
|
600
|
+
yield Button("Fetch", id="btn-fetch", variant="default")
|
|
601
|
+
|
|
536
602
|
yield Checkbox("Pull repo before creating", value=True, id="checkbox-pull")
|
|
537
603
|
|
|
538
604
|
with Horizontal(classes="modal-buttons"):
|
|
@@ -547,6 +613,18 @@ class CreateWorktreeFromIssueModal(ModalScreen[tuple[str, str, bool, bool] | Non
|
|
|
547
613
|
self.query_one("#path-preview", Label).update(f"Path: {path_preview}")
|
|
548
614
|
elif event.input.id == "input-branch":
|
|
549
615
|
self._branch = event.value
|
|
616
|
+
elif event.input.id == "input-base-branch":
|
|
617
|
+
self._base_branch = event.value
|
|
618
|
+
self._update_create_button_state()
|
|
619
|
+
|
|
620
|
+
def _update_create_button_state(self) -> None:
|
|
621
|
+
"""Enable/disable Create button based on base branch validation."""
|
|
622
|
+
btn = self.query_one("#btn-create", Button)
|
|
623
|
+
# Base branch must be in the list
|
|
624
|
+
if self._base_branch and self._base_branch not in self._branches:
|
|
625
|
+
btn.disabled = True
|
|
626
|
+
else:
|
|
627
|
+
btn.disabled = False
|
|
550
628
|
|
|
551
629
|
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
552
630
|
"""Handle checkbox changes."""
|
|
@@ -557,6 +635,8 @@ class CreateWorktreeFromIssueModal(ModalScreen[tuple[str, str, bool, bool] | Non
|
|
|
557
635
|
"""Handle button presses."""
|
|
558
636
|
if event.button.id == "btn-cancel":
|
|
559
637
|
self.dismiss(None)
|
|
638
|
+
elif event.button.id == "btn-fetch":
|
|
639
|
+
self._start_fetch()
|
|
560
640
|
elif event.button.id == "btn-create" and self._name and self._branch:
|
|
561
641
|
self.post_message(
|
|
562
642
|
self.WorktreeCreated(
|
|
@@ -565,10 +645,73 @@ class CreateWorktreeFromIssueModal(ModalScreen[tuple[str, str, bool, bool] | Non
|
|
|
565
645
|
self._branch,
|
|
566
646
|
True,
|
|
567
647
|
self._pull_first,
|
|
648
|
+
self._base_branch,
|
|
568
649
|
)
|
|
569
650
|
)
|
|
570
651
|
self.dismiss((self._name, self._branch, True, self._pull_first))
|
|
571
652
|
|
|
653
|
+
def _start_fetch(self) -> None:
|
|
654
|
+
"""Start fetching from remote."""
|
|
655
|
+
if self._is_fetching:
|
|
656
|
+
return
|
|
657
|
+
self._is_fetching = True
|
|
658
|
+
self._spinner_index = 0
|
|
659
|
+
try:
|
|
660
|
+
btn = self.query_one("#btn-fetch", Button)
|
|
661
|
+
btn.label = self._spinner_chars[0]
|
|
662
|
+
btn.disabled = True
|
|
663
|
+
self._spinner_timer = self.set_interval(0.1, self._tick_spinner)
|
|
664
|
+
except Exception:
|
|
665
|
+
pass
|
|
666
|
+
self.post_message(self.FetchRequested(self._repository.source_path))
|
|
667
|
+
|
|
668
|
+
def _tick_spinner(self) -> None:
|
|
669
|
+
"""Advance the spinner animation."""
|
|
670
|
+
self._spinner_index = (self._spinner_index + 1) % len(self._spinner_chars)
|
|
671
|
+
try:
|
|
672
|
+
btn = self.query_one("#btn-fetch", Button)
|
|
673
|
+
btn.label = self._spinner_chars[self._spinner_index]
|
|
674
|
+
except Exception:
|
|
675
|
+
self._stop_spinner()
|
|
676
|
+
|
|
677
|
+
def update_branches(self, branches: list[str]) -> None:
|
|
678
|
+
"""Update the branch list after fetch."""
|
|
679
|
+
self._is_fetching = False
|
|
680
|
+
self._branches = branches
|
|
681
|
+
self._reset_fetch_button()
|
|
682
|
+
try:
|
|
683
|
+
input_widget = self.query_one("#input-base-branch", Input)
|
|
684
|
+
# Update the suggester with new branches
|
|
685
|
+
input_widget.suggester = SuggestFromList(branches, case_sensitive=False)
|
|
686
|
+
# If current value is empty or not in new list, set to computed default
|
|
687
|
+
if not self._base_branch or self._base_branch not in branches:
|
|
688
|
+
self._base_branch = self._compute_default_base_branch()
|
|
689
|
+
input_widget.value = self._base_branch
|
|
690
|
+
self._update_create_button_state()
|
|
691
|
+
except Exception:
|
|
692
|
+
pass
|
|
693
|
+
|
|
694
|
+
def fetch_failed(self, _error: str) -> None:
|
|
695
|
+
"""Handle fetch failure - error is shown via app notification."""
|
|
696
|
+
self._is_fetching = False
|
|
697
|
+
self._reset_fetch_button()
|
|
698
|
+
|
|
699
|
+
def _stop_spinner(self) -> None:
|
|
700
|
+
"""Stop the spinner animation."""
|
|
701
|
+
if self._spinner_timer is not None:
|
|
702
|
+
self._spinner_timer.stop()
|
|
703
|
+
self._spinner_timer = None
|
|
704
|
+
|
|
705
|
+
def _reset_fetch_button(self) -> None:
|
|
706
|
+
"""Reset the fetch button to its default state."""
|
|
707
|
+
self._stop_spinner()
|
|
708
|
+
try:
|
|
709
|
+
btn = self.query_one("#btn-fetch", Button)
|
|
710
|
+
btn.label = "Fetch"
|
|
711
|
+
btn.disabled = False
|
|
712
|
+
except Exception:
|
|
713
|
+
pass
|
|
714
|
+
|
|
572
715
|
def action_cancel(self) -> None:
|
|
573
716
|
"""Cancel and close the modal."""
|
|
574
717
|
self.dismiss(None)
|
|
@@ -106,6 +106,12 @@ class WorktreeDetail(Widget):
|
|
|
106
106
|
f"Branch: {self._worktree.branch}",
|
|
107
107
|
classes="label-accent",
|
|
108
108
|
)
|
|
109
|
+
# Base branch info (if available)
|
|
110
|
+
if self._worktree.base_branch:
|
|
111
|
+
base_text = f"Based on: {self._worktree.base_branch}"
|
|
112
|
+
if self._worktree.created_from_ref:
|
|
113
|
+
base_text += f" ({self._worktree.created_from_ref})"
|
|
114
|
+
yield Label(base_text, classes="label-muted")
|
|
109
115
|
# Commit info
|
|
110
116
|
if self._commit_hash:
|
|
111
117
|
relative_time = (
|
|
@@ -69,6 +69,10 @@ class Worktree(BaseModel):
|
|
|
69
69
|
sort_order: int | None = None
|
|
70
70
|
last_modified: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
71
71
|
custom_claude_command: str | None = None
|
|
72
|
+
# Branch this worktree was created from (e.g., "origin/main")
|
|
73
|
+
base_branch: str | None = None
|
|
74
|
+
# Git commit ref when the worktree was created
|
|
75
|
+
created_from_ref: str | None = None
|
|
72
76
|
|
|
73
77
|
@field_validator("custom_claude_command")
|
|
74
78
|
@classmethod
|
|
@@ -73,47 +73,130 @@ class GitService:
|
|
|
73
73
|
raise GitError(f"Failed to get current branch: {stderr}")
|
|
74
74
|
return stdout or "HEAD"
|
|
75
75
|
|
|
76
|
-
async def list_branches(
|
|
77
|
-
|
|
76
|
+
async def list_branches(
|
|
77
|
+
self, path: str | Path, include_remote: bool = True
|
|
78
|
+
) -> list[str]:
|
|
79
|
+
"""List branches for a repository.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
path: Repository path
|
|
83
|
+
include_remote: If True, include remote branches with their prefix (e.g., origin/main)
|
|
84
|
+
"""
|
|
78
85
|
path = Path(path).expanduser()
|
|
86
|
+
|
|
87
|
+
# Get list of remotes to identify remote branches
|
|
88
|
+
remotes = await self._safe_list_remotes(path) if include_remote else []
|
|
89
|
+
|
|
79
90
|
code, stdout, stderr = await self._run_git(
|
|
80
91
|
"branch", "-a", "--format=%(refname:short)", cwd=path
|
|
81
92
|
)
|
|
82
93
|
if code != 0:
|
|
83
94
|
raise GitError(f"Failed to list branches: {stderr}")
|
|
95
|
+
|
|
84
96
|
branches = []
|
|
97
|
+
remote_prefixes = tuple(f"{r}/" for r in remotes)
|
|
98
|
+
remote_names = set(remotes)
|
|
99
|
+
|
|
85
100
|
for line in stdout.split("\n"):
|
|
86
101
|
line = line.strip()
|
|
87
|
-
if line
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
if not line or line.endswith("/HEAD"):
|
|
103
|
+
continue
|
|
104
|
+
# Skip bare remote names (e.g., "origin" without a branch)
|
|
105
|
+
if line in remote_names:
|
|
106
|
+
continue
|
|
107
|
+
# Check if it's a remote branch
|
|
108
|
+
is_remote = any(line.startswith(prefix) for prefix in remote_prefixes)
|
|
109
|
+
if is_remote:
|
|
110
|
+
if include_remote:
|
|
92
111
|
branches.append(line)
|
|
112
|
+
else:
|
|
113
|
+
branches.append(line)
|
|
93
114
|
return sorted(branches)
|
|
94
115
|
|
|
116
|
+
async def list_remotes(self, path: str | Path) -> list[str]:
|
|
117
|
+
"""List remote names for a repository."""
|
|
118
|
+
path = Path(path).expanduser()
|
|
119
|
+
code, stdout, stderr = await self._run_git("remote", cwd=path)
|
|
120
|
+
if code != 0:
|
|
121
|
+
raise GitError(f"Failed to list remotes: {stderr}")
|
|
122
|
+
return [r.strip() for r in stdout.split("\n") if r.strip()]
|
|
123
|
+
|
|
124
|
+
async def _safe_list_remotes(self, path: str | Path) -> list[str]:
|
|
125
|
+
"""List remotes, returning empty list on error."""
|
|
126
|
+
try:
|
|
127
|
+
return await self.list_remotes(path)
|
|
128
|
+
except GitError:
|
|
129
|
+
return []
|
|
130
|
+
|
|
95
131
|
async def create_worktree(
|
|
96
132
|
self,
|
|
97
133
|
repo_path: str | Path,
|
|
98
134
|
worktree_path: str | Path,
|
|
99
135
|
branch: str,
|
|
100
136
|
new_branch: bool = True,
|
|
137
|
+
base_branch: str | None = None,
|
|
101
138
|
) -> None:
|
|
102
|
-
"""Create a new worktree.
|
|
139
|
+
"""Create a new worktree.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
repo_path: Path to the source repository
|
|
143
|
+
worktree_path: Path where the worktree will be created
|
|
144
|
+
branch: Branch name (new or existing)
|
|
145
|
+
new_branch: If True, create a new branch; if False, use existing branch
|
|
146
|
+
base_branch: Base branch to stem from (only used when new_branch=True)
|
|
147
|
+
"""
|
|
103
148
|
repo_path = Path(repo_path).expanduser()
|
|
104
149
|
worktree_path = Path(worktree_path).expanduser()
|
|
105
150
|
|
|
106
151
|
# Ensure parent directory exists
|
|
107
152
|
worktree_path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
153
|
|
|
154
|
+
# Get remotes to detect remote branches
|
|
155
|
+
remotes = await self._safe_list_remotes(repo_path)
|
|
156
|
+
|
|
109
157
|
if new_branch:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
158
|
+
# git worktree add -b <new-branch> <path> [<base-branch>]
|
|
159
|
+
args = ["worktree", "add", "-b", branch, str(worktree_path)]
|
|
160
|
+
if base_branch:
|
|
161
|
+
args.append(base_branch)
|
|
162
|
+
code, _stdout, stderr = await self._run_git(*args, cwd=repo_path)
|
|
163
|
+
|
|
164
|
+
# If base is a remote branch, git may auto-set upstream (branch.autoSetupMerge)
|
|
165
|
+
# Unset it - new branches should be pushed with `git push -u origin <branch>`
|
|
166
|
+
if code == 0 and base_branch:
|
|
167
|
+
is_remote_base = any(base_branch.startswith(f"{r}/") for r in remotes)
|
|
168
|
+
if is_remote_base:
|
|
169
|
+
await self._run_git(
|
|
170
|
+
"branch", "--unset-upstream", branch, cwd=worktree_path
|
|
171
|
+
)
|
|
113
172
|
else:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
173
|
+
# Check if this is a remote branch (e.g., origin/feature-branch)
|
|
174
|
+
# If so, create a local tracking branch to avoid detached HEAD
|
|
175
|
+
remote_prefix = None
|
|
176
|
+
for remote in remotes:
|
|
177
|
+
if branch.startswith(f"{remote}/"):
|
|
178
|
+
remote_prefix = f"{remote}/"
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
if remote_prefix:
|
|
182
|
+
# Extract local branch name from remote branch
|
|
183
|
+
local_branch = branch[len(remote_prefix) :]
|
|
184
|
+
# git worktree add --track -b <local-branch> <path> <remote-branch>
|
|
185
|
+
code, _stdout, stderr = await self._run_git(
|
|
186
|
+
"worktree",
|
|
187
|
+
"add",
|
|
188
|
+
"--track",
|
|
189
|
+
"-b",
|
|
190
|
+
local_branch,
|
|
191
|
+
str(worktree_path),
|
|
192
|
+
branch,
|
|
193
|
+
cwd=repo_path,
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
# Local branch - checkout directly
|
|
197
|
+
code, _stdout, stderr = await self._run_git(
|
|
198
|
+
"worktree", "add", str(worktree_path), branch, cwd=repo_path
|
|
199
|
+
)
|
|
117
200
|
|
|
118
201
|
if code != 0:
|
|
119
202
|
raise GitError(f"Failed to create worktree: {stderr}")
|
|
@@ -209,6 +292,16 @@ class GitService:
|
|
|
209
292
|
branches = await self.list_branches(repo_path)
|
|
210
293
|
return branch in branches
|
|
211
294
|
|
|
295
|
+
async def get_ref(self, path: str | Path, ref: str = "HEAD") -> str | None:
|
|
296
|
+
"""Get the short commit hash for a ref (branch, tag, or HEAD)."""
|
|
297
|
+
path = Path(path).expanduser()
|
|
298
|
+
code, stdout, _stderr = await self._run_git(
|
|
299
|
+
"rev-parse", "--short", ref, cwd=path
|
|
300
|
+
)
|
|
301
|
+
if code != 0:
|
|
302
|
+
return None
|
|
303
|
+
return stdout.strip() or None
|
|
304
|
+
|
|
212
305
|
async def get_latest_commit(self, path: str | Path) -> CommitInfo:
|
|
213
306
|
"""Get the latest commit info for a repository."""
|
|
214
307
|
path = Path(path).expanduser()
|
|
@@ -246,8 +246,8 @@ ModalScreen {
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
.modal-container {
|
|
249
|
-
width:
|
|
250
|
-
max-width:
|
|
249
|
+
width: 80;
|
|
250
|
+
max-width: 90%;
|
|
251
251
|
height: auto;
|
|
252
252
|
max-height: 90%;
|
|
253
253
|
background: $bg-elevated;
|
|
@@ -521,6 +521,21 @@ Container {
|
|
|
521
521
|
margin-right: 1;
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
+
/* Base branch row in modal */
|
|
525
|
+
.base-branch-row {
|
|
526
|
+
height: 3;
|
|
527
|
+
width: 100%;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.base-branch-row Input {
|
|
531
|
+
width: 1fr;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.base-branch-row Button {
|
|
535
|
+
min-width: 10;
|
|
536
|
+
margin-left: 1;
|
|
537
|
+
}
|
|
538
|
+
|
|
524
539
|
/* Branch tag */
|
|
525
540
|
.branch-tag {
|
|
526
541
|
background: $accent-dark;
|
|
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
|