Skip to content

Commit 753e8d6

Browse files
committed
feat: add tag selection for worktree creation
1 parent 5b03dfc commit 753e8d6

File tree

8 files changed

+376
-43
lines changed

8 files changed

+376
-43
lines changed

CLAUDE.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,11 @@ source /path/to/git-workers/shell/gw.sh
7676
- Security validation to prevent path traversal attacks
7777
- Follows same discovery priority as configuration files
7878

79-
### Branch Option Simplification
79+
### Branch Option Enhancement
8080

81-
- Reduced from 3 options to 2: "Create from current HEAD" and "Select branch (smart mode)"
82-
- Smart mode automatically handles branch conflicts and offers appropriate actions
81+
- Enhanced from 2 options to 3: "Create from current HEAD", "Select branch", and "Select tag"
82+
- Branch selection automatically handles conflicts and offers appropriate actions
83+
- Tag selection allows creating worktrees from specific versions
8384

8485
### Custom Path Support
8586

@@ -96,11 +97,13 @@ source /path/to/git-workers/shell/gw.sh
9697

9798
- **`get_branch_worktree_map()`**: Maps branch names to worktree names, including main worktree detection
9899
- **`list_all_branches()`**: Returns both local and remote branches (remote without "origin/" prefix)
100+
- **`list_all_tags()`**: Returns all tags with optional messages for annotated tags
99101
- **`create_worktree_with_new_branch()`**: Creates worktree with new branch from base branch (supports git-flow style workflows)
102+
- **`create_worktree_with_branch()`**: Enhanced to handle tag references for creating worktrees at specific versions
100103
- **`copy_configured_files()`**: Copies files specified in config to new worktrees
101104
- **`create_worktree_from_head()`**: Fixed path resolution for non-bare repositories (converts relative paths to absolute)
102105
- **`validate_custom_path()`**: Validates custom paths for security and compatibility
103-
- **`create_worktree_internal()`**: Enhanced with custom path input option
106+
- **`create_worktree_internal()`**: Enhanced with custom path input option and tag selection
104107

105108
## Architecture
106109

@@ -198,6 +201,7 @@ Since Git lacks native rename functionality:
198201
- New test files added:
199202
- `worktree_path_test.rs`: Tests for path resolution and edge cases
200203
- `create_worktree_integration_test.rs`: Integration tests for worktree creation
204+
- `create_worktree_from_tag_test.rs`: Tests for tag listing and worktree creation from tags
201205

202206
### String Formatting
203207

@@ -332,3 +336,7 @@ The following test files have been added/updated for v0.3.0:
332336
- `tests/worktree_lock_test.rs`: 5 tests for concurrent access control
333337
- `tests/validate_custom_path_test.rs`: 9 tests for custom path validation including security checks
334338
- Enhanced `tests/create_worktree_integration_test.rs`: 2 additional tests for custom path creation
339+
- `tests/create_worktree_from_tag_test.rs`: 3 tests for tag functionality:
340+
- `test_list_all_tags`: Tests tag listing with both lightweight and annotated tags
341+
- `test_create_worktree_from_tag`: Tests creating worktree from tag with new branch
342+
- `test_create_worktree_from_tag_detached`: Tests creating detached HEAD worktree from tag

README.md

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,22 @@
33
[![CI](https://github.com/wasabeef/git-workers/actions/workflows/ci.yml/badge.svg)](https://github.com/wasabeef/git-workers/actions/workflows/ci.yml)
44
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
55

6-
An interactive CLI tool for managing Git worktrees with ease.
6+
An interactive CLI tool for managing Git worktrees.
77

88
https://github.com/user-attachments/assets/fb5f0213-4a9f-43e2-9557-416070d7e122
99

1010
## Features
1111

12-
- 📋 List worktrees with detailed status information (branch, changes, ahead/behind)
13-
- 🔍 Fuzzy search through worktrees and branches
14-
- Create new worktrees from branches or HEAD
15-
- 📄 Automatically copy gitignored files (like .env) to new worktrees
16-
- Delete single or multiple worktrees
17-
- 🔄 Switch worktrees with automatic directory change
18-
- ✏️ Rename worktrees and optionally their branches
19-
- 🧹 Cleanup old worktrees by age
20-
- 🪝 Execute hooks on worktree lifecycle events
21-
- 📝 Edit and manage hooks through the interface
12+
- List worktrees with detailed status information (branch, changes, ahead/behind)
13+
- Fuzzy search through worktrees and branches
14+
- Create new worktrees from branches, tags, or HEAD
15+
- Automatically copy gitignored files (.env) to new worktrees
16+
- Delete single or multiple worktrees
17+
- Switch worktrees with automatic directory change
18+
- Rename worktrees and optionally their branches
19+
- Cleanup old worktrees by age
20+
- Execute hooks on worktree lifecycle events
21+
- Edit and manage hooks through the interface
2222

2323
## Installation
2424

@@ -55,22 +55,26 @@ gw
5555

5656
Git Workers provides an interactive menu-driven interface. Simply run `gw` and navigate through the options:
5757

58-
- **List worktrees** (``): Display all worktrees with branch, changes, and sync status
59-
- **Search worktrees** (`?`): Fuzzy search through worktree names and branches
60-
- **Create worktree** (`+`): Create a new worktree with two options:
61-
- **Create from current HEAD**: Creates a new worktree with a new branch from the current HEAD
62-
- **Select branch (smart mode)**: Choose from local/remote branches with fuzzy search:
58+
- List worktrees (``) - Display all worktrees with branch, changes, and sync status
59+
- Search worktrees (`?`) - Fuzzy search through worktree names and branches
60+
- Create worktree (`+`) - Create a new worktree with three options:
61+
- Create from current HEAD - Creates a new worktree with a new branch from the current HEAD
62+
- Select branch - Choose from local/remote branches with fuzzy search:
6363
- Shows local branches (💻) and remote branches (⛅️) with usage status
6464
- Automatically handles branch conflicts (offers to create new branch if already in use)
65-
- Fuzzy search enabled when >10 branches for easy navigation
66-
- Automatically copies configured files (.env, etc.) to new worktrees
67-
- **Delete worktree** (`-`): Delete a single worktree with safety checks
68-
- **Batch delete** (`=`): Select and delete multiple worktrees at once (optionally deletes orphaned branches)
69-
- **Cleanup old worktrees** (`~`): Remove worktrees older than specified days
70-
- **Switch worktree** (``): Switch to another worktree (automatically changes directory)
71-
- **Rename worktree** (`*`): Rename worktree directory and optionally its branch
72-
- **Edit hooks** (`λ`): Configure lifecycle hooks in `.git-workers.toml`
73-
- **Exit** (`x`): Exit the application
65+
- Fuzzy search enabled when >5 branches
66+
- Select tag - Choose from tags to create a worktree at a specific version:
67+
- Shows all tags (🏷️) with messages for annotated tags
68+
- Creates a new branch from the selected tag
69+
- Fuzzy search enabled when >5 tags
70+
- Automatically copies configured files (.env, etc.) to new worktrees
71+
- Delete worktree (`-`) - Delete a single worktree with safety checks
72+
- Batch delete (`=`) - Select and delete multiple worktrees at once (optionally deletes orphaned branches)
73+
- Cleanup old worktrees (`~`) - Remove worktrees older than specified days
74+
- Switch worktree (``) - Switch to another worktree (automatically changes directory)
75+
- Rename worktree (`*`) - Rename worktree directory and optionally its branch
76+
- Edit hooks (`λ`) - Configure lifecycle hooks in `.git-workers.toml`
77+
- Exit (`x`) - Exit the application
7478

7579
### Configuration
7680

src/commands.rs

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,8 @@ fn search_worktrees_internal(manager: &GitWorktreeManager) -> Result<bool> {
398398
/// - Custom path: User-specified relative path (e.g., `../custom/path`)
399399
/// 3. **Branch Selection**:
400400
/// - Create from current HEAD
401-
/// - Create from existing branch (shows branch list)
401+
/// - Select branch (shows local/remote branch list)
402+
/// - Select tag (shows tag list)
402403
/// 4. **Creation**: Creates the worktree with progress indication
403404
/// 5. **File Copy**: Copies configured files (e.g., `.env`) from main worktree
404405
/// 6. **Post-create Hooks**: Executes any configured post-create hooks
@@ -581,7 +582,7 @@ fn create_worktree_internal(manager: &GitWorktreeManager) -> Result<bool> {
581582

582583
// Branch handling
583584
println!();
584-
let branch_options = vec!["Create from current HEAD", "Select branch (smart mode)"];
585+
let branch_options = vec!["Create from current HEAD", "Select branch", "Select tag"];
585586

586587
let branch_choice = match Select::with_theme(&get_theme())
587588
.with_prompt("Select branch option")
@@ -594,7 +595,7 @@ fn create_worktree_internal(manager: &GitWorktreeManager) -> Result<bool> {
594595

595596
let (branch, new_branch_name) = match branch_choice {
596597
1 => {
597-
// Select branch (smart mode)
598+
// Select branch
598599
let (local_branches, remote_branches) = manager.list_all_branches()?;
599600
if local_branches.is_empty() && remote_branches.is_empty() {
600601
utils::print_warning("No branches found, creating from HEAD");
@@ -632,7 +633,7 @@ fn create_worktree_internal(manager: &GitWorktreeManager) -> Result<bool> {
632633
println!();
633634

634635
// Use FuzzySelect for better search experience when there are many branches
635-
let selection_result = if branch_items.len() > 10 {
636+
let selection_result = if branch_items.len() > 5 {
636637
println!("Type to search branches (fuzzy search enabled):");
637638
FuzzySelect::with_theme(&get_theme())
638639
.with_prompt("Select a branch")
@@ -785,6 +786,58 @@ fn create_worktree_internal(manager: &GitWorktreeManager) -> Result<bool> {
785786
}
786787
}
787788
}
789+
2 => {
790+
// Select tag
791+
let tags = manager.list_all_tags()?;
792+
if tags.is_empty() {
793+
utils::print_warning("No tags found, creating from HEAD");
794+
(None, None)
795+
} else {
796+
// Create items for tag selection with message preview
797+
let tag_items: Vec<String> = tags
798+
.iter()
799+
.map(|(name, message)| {
800+
if let Some(msg) = message {
801+
// Truncate message to first line for display
802+
let first_line = msg.lines().next().unwrap_or("");
803+
let truncated = if first_line.len() > 50 {
804+
format!("{}...", &first_line[..50])
805+
} else {
806+
first_line.to_string()
807+
};
808+
format!("🏷️ {name} - {truncated}")
809+
} else {
810+
format!("🏷️ {name}")
811+
}
812+
})
813+
.collect();
814+
815+
println!();
816+
817+
// Use FuzzySelect for better search experience when there are many tags
818+
let selection_result = if tag_items.len() > 5 {
819+
println!("Type to search tags (fuzzy search enabled):");
820+
FuzzySelect::with_theme(&get_theme())
821+
.with_prompt("Select a tag")
822+
.items(&tag_items)
823+
.interact_opt()?
824+
} else {
825+
Select::with_theme(&get_theme())
826+
.with_prompt("Select a tag")
827+
.items(&tag_items)
828+
.interact_opt()?
829+
};
830+
831+
match selection_result {
832+
Some(selection) => {
833+
let selected_tag = &tags[selection].0;
834+
// For tags, we always create a new branch named after the worktree
835+
(Some(selected_tag.clone()), Some(name.clone()))
836+
}
837+
None => return Ok(false),
838+
}
839+
}
840+
}
788841
_ => {
789842
// Create from current HEAD
790843
(None, None)
@@ -800,10 +853,22 @@ fn create_worktree_internal(manager: &GitWorktreeManager) -> Result<bool> {
800853
println!(" {name_label} {name_value}");
801854
if let Some(new_branch) = &new_branch_name {
802855
let base_branch_name = branch.as_ref().unwrap();
803-
let branch_label = "New Branch:".bright_black();
804-
let branch_value = new_branch.yellow();
805-
let base_value = base_branch_name.bright_black();
806-
println!(" {branch_label} {branch_value} (from {base_value})");
856+
// Check if the base branch is a tag
857+
if manager
858+
.repo()
859+
.find_reference(&format!("refs/tags/{base_branch_name}"))
860+
.is_ok()
861+
{
862+
let branch_label = "New Branch:".bright_black();
863+
let branch_value = new_branch.yellow();
864+
let tag_value = format!("tag: {base_branch_name}").bright_cyan();
865+
println!(" {branch_label} {branch_value} (from {tag_value})");
866+
} else {
867+
let branch_label = "New Branch:".bright_black();
868+
let branch_value = new_branch.yellow();
869+
let base_value = base_branch_name.bright_black();
870+
println!(" {branch_label} {branch_value} (from {base_value})");
871+
}
807872
} else if let Some(branch_name) = &branch {
808873
let branch_label = "Branch:".bright_black();
809874
let branch_value = branch_name.yellow();

src/git.rs

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -740,18 +740,19 @@ impl GitWorktreeManager {
740740
self.get_default_worktree_base_path()
741741
}
742742

743-
/// Creates a worktree with a specific branch
743+
/// Creates a worktree with a specific branch or tag
744744
///
745745
/// Uses the git CLI command for branch-based worktree creation because
746746
/// git2's worktree API has limitations with branch handling.
747747
///
748748
/// # Arguments
749749
///
750750
/// * `path` - The filesystem path for the new worktree
751-
/// * `branch_name` - The branch to check out in the worktree
751+
/// * `branch_name` - The branch or tag to check out in the worktree
752752
///
753753
/// # Behavior
754754
///
755+
/// - If a tag reference: Creates worktree at the tag commit (detached HEAD)
755756
/// - If the branch exists: Creates worktree with that branch checked out
756757
/// - If the branch doesn't exist: Creates a new branch and worktree
757758
///
@@ -768,8 +769,16 @@ impl GitWorktreeManager {
768769
let mut cmd = Command::new("git");
769770
cmd.current_dir(self.get_git_dir()?);
770771

771-
// Check if this is a remote branch reference (e.g., "origin/feature")
772-
if branch_name.starts_with(GIT_REMOTE_PREFIX) {
772+
// Check if this is a tag reference
773+
if self
774+
.repo
775+
.find_reference(&format!("refs/tags/{branch_name}"))
776+
.is_ok()
777+
{
778+
// For tags, we need to create a detached HEAD worktree
779+
// git worktree add <path> <tag>
780+
cmd.arg("worktree").arg("add").arg(path).arg(branch_name);
781+
} else if branch_name.starts_with(GIT_REMOTE_PREFIX) {
773782
// For remote branches, we need to create a local branch
774783
// Extract the branch name without "origin/" prefix
775784
let local_branch_name = branch_name
@@ -987,6 +996,68 @@ impl GitWorktreeManager {
987996
Ok((local_branches, remote_branches))
988997
}
989998

999+
/// Lists all tags in the repository
1000+
///
1001+
/// This method retrieves all tags, including both lightweight and annotated tags.
1002+
/// For annotated tags, it includes the tag message if available.
1003+
///
1004+
/// # Returns
1005+
///
1006+
/// A vector of tuples containing:
1007+
/// - Tag name (`String`)
1008+
/// - Tag message (`Option<String>`) - Some for annotated tags, None for lightweight tags
1009+
///
1010+
/// # Errors
1011+
///
1012+
/// Returns an error if tag enumeration fails
1013+
///
1014+
/// # Example
1015+
///
1016+
/// ```no_run
1017+
/// # use git_workers::git::GitWorktreeManager;
1018+
/// # let manager = GitWorktreeManager::new().unwrap();
1019+
/// let tags = manager.list_all_tags().unwrap();
1020+
/// for (name, message) in tags {
1021+
/// if let Some(msg) = message {
1022+
/// println!("Tag: {} - {}", name, msg);
1023+
/// } else {
1024+
/// println!("Tag: {}", name);
1025+
/// }
1026+
/// }
1027+
/// ```
1028+
pub fn list_all_tags(&self) -> Result<Vec<(String, Option<String>)>> {
1029+
let mut tags = Vec::new();
1030+
1031+
self.repo.tag_foreach(|oid, name| {
1032+
if let Ok(name_str) = std::str::from_utf8(name) {
1033+
// Remove refs/tags/ prefix
1034+
let tag_name = name_str
1035+
.strip_prefix("refs/tags/")
1036+
.unwrap_or(name_str)
1037+
.to_string();
1038+
1039+
// Try to get tag message for annotated tags
1040+
let tag_message = if let Ok(obj) = self.repo.find_object(oid, None) {
1041+
if let Ok(tag) = obj.peel_to_tag() {
1042+
tag.message().map(|s| s.to_string())
1043+
} else {
1044+
None
1045+
}
1046+
} else {
1047+
None
1048+
};
1049+
1050+
tags.push((tag_name, tag_message));
1051+
}
1052+
true
1053+
})?;
1054+
1055+
// Sort tags by name (reverse order to show newest versions first)
1056+
tags.sort_by(|a, b| b.0.cmp(&a.0));
1057+
1058+
Ok(tags)
1059+
}
1060+
9901061
/// Deletes a local branch by name
9911062
///
9921063
/// # Arguments

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
//! - **Rename Support**: Complete worktree renaming including Git metadata
1515
//! - **Custom Paths**: Flexible worktree placement with validated custom paths
1616
//! - **File Copying**: Automatically copy configured files to new worktrees
17+
//! - **Tag Support**: Create worktrees from specific Git tags
1718
//!
1819
//! # Architecture
1920
//!

0 commit comments

Comments
 (0)