forestui 0.9.8__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.
Files changed (31) hide show
  1. {forestui-0.9.8 → forestui-1.0.0}/PKG-INFO +1 -1
  2. {forestui-0.9.8 → forestui-1.0.0}/forestui/app.py +79 -12
  3. {forestui-0.9.8 → forestui-1.0.0}/forestui/components/modals.py +159 -16
  4. {forestui-0.9.8 → forestui-1.0.0}/forestui/components/worktree_detail.py +6 -0
  5. {forestui-0.9.8 → forestui-1.0.0}/forestui/models.py +4 -0
  6. {forestui-0.9.8 → forestui-1.0.0}/forestui/services/git.py +107 -14
  7. {forestui-0.9.8 → forestui-1.0.0}/forestui/theme.py +17 -2
  8. {forestui-0.9.8 → forestui-1.0.0}/pyproject.toml +1 -1
  9. {forestui-0.9.8 → forestui-1.0.0}/.github/workflows/publish.yml +0 -0
  10. {forestui-0.9.8 → forestui-1.0.0}/.gitignore +0 -0
  11. {forestui-0.9.8 → forestui-1.0.0}/.pre-commit-config.yaml +0 -0
  12. {forestui-0.9.8 → forestui-1.0.0}/.python-version +0 -0
  13. {forestui-0.9.8 → forestui-1.0.0}/CLAUDE.md +0 -0
  14. {forestui-0.9.8 → forestui-1.0.0}/Makefile +0 -0
  15. {forestui-0.9.8 → forestui-1.0.0}/README.md +0 -0
  16. {forestui-0.9.8 → forestui-1.0.0}/doc/screenshot_small.png +0 -0
  17. {forestui-0.9.8 → forestui-1.0.0}/forestui/__init__.py +0 -0
  18. {forestui-0.9.8 → forestui-1.0.0}/forestui/__main__.py +0 -0
  19. {forestui-0.9.8 → forestui-1.0.0}/forestui/cli.py +0 -0
  20. {forestui-0.9.8 → forestui-1.0.0}/forestui/components/__init__.py +0 -0
  21. {forestui-0.9.8 → forestui-1.0.0}/forestui/components/messages.py +0 -0
  22. {forestui-0.9.8 → forestui-1.0.0}/forestui/components/repository_detail.py +0 -0
  23. {forestui-0.9.8 → forestui-1.0.0}/forestui/components/sidebar.py +0 -0
  24. {forestui-0.9.8 → forestui-1.0.0}/forestui/services/__init__.py +0 -0
  25. {forestui-0.9.8 → forestui-1.0.0}/forestui/services/claude_session.py +0 -0
  26. {forestui-0.9.8 → forestui-1.0.0}/forestui/services/github.py +0 -0
  27. {forestui-0.9.8 → forestui-1.0.0}/forestui/services/settings.py +0 -0
  28. {forestui-0.9.8 → forestui-1.0.0}/forestui/services/tmux.py +0 -0
  29. {forestui-0.9.8 → forestui-1.0.0}/forestui/state.py +0 -0
  30. {forestui-0.9.8 → forestui-1.0.0}/install.sh +0 -0
  31. {forestui-0.9.8 → forestui-1.0.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forestui
3
- Version: 0.9.8
3
+ Version: 1.0.0
4
4
  Summary: A Terminal UI for managing Git worktrees
5
5
  Author: cadu
6
6
  Keywords: git,terminal,textual,tui,worktree
@@ -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, issue, branches, get_forest_path(), settings.branch_prefix
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, worktree_path, event.branch, event.new_branch
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, branch=event.branch, path=str(worktree_path)
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, branch=event.branch, path=str(worktree_path)
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)
@@ -784,13 +851,13 @@ class ForestApp(App[None]):
784
851
  return repo.name
785
852
  return None
786
853
 
787
- def _get_claude_window_name(self, path: str) -> str:
788
- """Get the window name for Claude sessions: repo:branch format."""
854
+ def _get_tmux_window_name(self, path: str) -> str:
855
+ """Get the window name for Claude sessions: repo:worktree format."""
789
856
  # Check worktrees first
790
857
  for repo in self._state.repositories:
791
858
  for worktree in repo.worktrees:
792
859
  if worktree.path == path:
793
- return f"{repo.name}:{worktree.branch}"
860
+ return f"{repo.name}:{worktree.name}"
794
861
  # Check if it's the repository source path
795
862
  if repo.source_path == path:
796
863
  return repo.name
@@ -804,7 +871,7 @@ class ForestApp(App[None]):
804
871
  if self._tmux_service.is_inside_tmux and self._tmux_service.is_tui_editor(
805
872
  editor
806
873
  ):
807
- name = self._get_claude_window_name(path)
874
+ name = self._get_tmux_window_name(path)
808
875
  if self._tmux_service.create_editor_window(name, path, editor):
809
876
  self.notify(f"Opened {editor} in edit:{name}")
810
877
  return
@@ -824,7 +891,7 @@ class ForestApp(App[None]):
824
891
 
825
892
  def _open_in_terminal(self, path: str) -> None:
826
893
  """Open path in a tmux terminal window."""
827
- name = self._get_claude_window_name(path)
894
+ name = self._get_tmux_window_name(path)
828
895
  if self._tmux_service.create_shell_window(name, path):
829
896
  self.notify(f"Opened terminal in term:{name}")
830
897
  else:
@@ -832,7 +899,7 @@ class ForestApp(App[None]):
832
899
 
833
900
  def _open_in_file_manager(self, path: str) -> None:
834
901
  """Open path in Midnight Commander (mc) in a tmux window."""
835
- name = self._get_claude_window_name(path)
902
+ name = self._get_tmux_window_name(path)
836
903
  if self._tmux_service.create_mc_window(name, path):
837
904
  self.notify(f"Opened mc in files:{name}")
838
905
  else:
@@ -922,7 +989,7 @@ class ForestApp(App[None]):
922
989
 
923
990
  def _start_claude_session(self, path: str, yolo: bool = False) -> None:
924
991
  """Start a new Claude session in a tmux window."""
925
- name = self._get_claude_window_name(path)
992
+ name = self._get_tmux_window_name(path)
926
993
  custom_command = self._resolve_claude_command(path)
927
994
  window_name = self._tmux_service.create_claude_window(
928
995
  name, path, yolo=yolo, custom_command=custom_command
@@ -937,7 +1004,7 @@ class ForestApp(App[None]):
937
1004
  self, session_id: str, path: str, yolo: bool = False
938
1005
  ) -> None:
939
1006
  """Continue an existing Claude session in a tmux window."""
940
- name = self._get_claude_window_name(path)
1007
+ name = self._get_tmux_window_name(path)
941
1008
  custom_command = self._resolve_claude_command(path)
942
1009
  window_name = self._tmux_service.create_claude_window(
943
1010
  name,
@@ -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 Select(
207
- [(b, b) for b in self._branches],
208
- id="select-branch",
209
- prompt="Select a branch...",
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 select by default
221
- self.query_one("#select-branch", Select).display = False
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 == "input-branch":
238
+ elif event.input.id in ("input-branch", "input-existing-branch"):
234
239
  self._branch = event.value
235
240
 
236
- def on_select_changed(self, event: Select.Changed) -> None:
237
- """Handle select changes."""
238
- if event.select.id == "select-branch" and event.value:
239
- self._branch = str(event.value)
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
- branch_select = self.query_one("#select-branch", Select)
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
- branch_select.display = False
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
- branch_select.display = True
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(" Name is required")
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(self, path: str | Path) -> list[str]:
77
- """List all branches (local and remote) for a repository."""
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 and not line.startswith("origin/HEAD"):
88
- # Clean up remote branch names
89
- if line.startswith("origin/"):
90
- line = line[7:]
91
- if line and line not in branches:
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
- code, _stdout, stderr = await self._run_git(
111
- "worktree", "add", "-b", branch, str(worktree_path), cwd=repo_path
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
- code, _stdout, stderr = await self._run_git(
115
- "worktree", "add", str(worktree_path), branch, cwd=repo_path
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: 60;
250
- max-width: 80%;
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;
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forestui"
3
- version = "0.9.8"
3
+ version = "1.0.0"
4
4
  description = "A Terminal UI for managing Git worktrees"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
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