Skip to content

Commit a7d46e3

Browse files
feat: add support for configuring branch name prefix (#75)
* feat: support branch prefix for implicit add names Co-authored-by: Safia Abdalla <safia@safia.rocks> * fix: keep implicit worktree directories unprefixed Co-authored-by: Safia Abdalla <safia@safia.rocks> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 132dcce commit a7d46e3

4 files changed

Lines changed: 125 additions & 30 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ Create a new worktree with an auto-generated adjective-noun name:
8787
```bash
8888
grove add
8989
# Example generated name: quiet-meadow
90+
# If .groverc sets "branchPrefix": "safia", example: safia/quiet-meadow
91+
# Directory remains: quiet-meadow
9092
```
9193

9294
Track a remote branch:
@@ -99,6 +101,7 @@ Bootstrap a newly created worktree with project-scoped commands:
99101

100102
```json
101103
{
104+
"branchPrefix": "safia",
102105
"bootstrap": {
103106
"commands": [
104107
{ "program": "npm", "args": ["install"] },
@@ -110,6 +113,8 @@ Bootstrap a newly created worktree with project-scoped commands:
110113

111114
Save this as `.groverc` in your Grove project root (the directory that contains your bare clone, for example `repo/.groverc` next to `repo/repo.git`).
112115

116+
When `grove add` is called without an explicit branch name, Grove generates an adjective-noun name and prepends `branchPrefix` to the branch name when configured. The worktree directory keeps the generated base name.
117+
113118
When `grove add` creates a worktree, it runs each bootstrap command in order inside that new worktree directory.
114119

115120
- Commands must be portable across Linux/macOS/Windows.

site/index.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,11 +799,14 @@ <h3>Add a new worktree</h3>
799799
<pre><code>grove add feature-branch</code></pre>
800800
<p>Or let Grove generate a default adjective-noun name:</p>
801801
<pre><code>grove add
802-
# Example generated name: quiet-meadow</code></pre>
802+
# Example generated name: quiet-meadow
803+
# If .groverc sets "branchPrefix": "safia", example: safia/quiet-meadow
804+
# Directory remains: quiet-meadow</code></pre>
803805
<p>With tracking for a remote branch:</p>
804806
<pre><code>grove add feature-branch --track origin/feature-branch</code></pre>
805807
<p>Optional bootstrap commands from <code>.groverc</code> run in the new worktree:</p>
806808
<pre><code>{
809+
"branchPrefix": "safia",
807810
"bootstrap": {
808811
"commands": [
809812
{ "program": "npm", "args": ["install"] },
@@ -812,6 +815,7 @@ <h3>Add a new worktree</h3>
812815
}
813816
}</code></pre>
814817
<p>Place <code>.groverc</code> in the Grove project root (next to the bare clone directory).</p>
818+
<p>When <code>grove add</code> is called without an explicit branch name, Grove generates an adjective-noun name and prepends <code>branchPrefix</code> to the branch name when configured. The worktree directory keeps the generated base name.</p>
815819
<p>Commands must be portable across Linux/macOS/Windows and use executable + args only (no shell operators like <code>&amp;&amp;</code> or pipes). If one command fails, Grove continues and reports a partial bootstrap state.</p>
816820
</div>
817821

src/commands/add.rs

Lines changed: 93 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use crate::git::{
66
add_worktree, branch_exists, discover_repo, project_root, tracked_branch_name, RepoContext,
77
};
88
use crate::utils::{
9-
default_worktree_name_seed, generate_default_worktree_name, read_repo_config, BootstrapCommand,
10-
DEFAULT_WORKTREE_NAME_ATTEMPTS,
9+
default_worktree_name_seed, generate_default_worktree_name, read_repo_config,
10+
trim_trailing_branch_slashes, BootstrapCommand, RepoConfig, DEFAULT_WORKTREE_NAME_ATTEMPTS,
1111
};
1212

1313
#[derive(Debug)]
@@ -17,6 +17,12 @@ struct BootstrapSummary {
1717
failed: Vec<(String, String)>,
1818
}
1919

20+
#[derive(Debug, Clone, PartialEq, Eq)]
21+
struct WorktreeSpec {
22+
directory_name: String,
23+
branch_name: String,
24+
}
25+
2026
pub fn run(name: Option<&str>, track: Option<&str>) {
2127
let repo = match discover_repo() {
2228
Ok(m) => m,
@@ -27,14 +33,21 @@ pub fn run(name: Option<&str>, track: Option<&str>) {
2733
};
2834

2935
let project_root = project_root(&repo);
30-
let worktree_name = match resolve_worktree_name(name, &repo, project_root) {
31-
Ok(name) => name,
36+
let repo_config = match read_repo_config(project_root) {
37+
Ok(config) => config,
38+
Err(e) => {
39+
eprintln!("{} {}", "Warning:".yellow(), e);
40+
RepoConfig::default()
41+
}
42+
};
43+
let worktree = match resolve_worktree_spec(name, &repo, project_root, &repo_config) {
44+
Ok(worktree) => worktree,
3245
Err(e) => {
3346
eprintln!("{} {}", "Error:".red(), e);
3447
std::process::exit(1);
3548
}
3649
};
37-
let worktree_path = match get_worktree_path(&worktree_name, project_root) {
50+
let worktree_path = match get_worktree_path(&worktree.directory_name, project_root) {
3851
Ok(p) => p,
3952
Err(e) => {
4053
eprintln!("{} {}", "Error:".red(), e);
@@ -43,7 +56,7 @@ pub fn run(name: Option<&str>, track: Option<&str>) {
4356
};
4457

4558
let worktree_path_str = worktree_path.to_string_lossy().to_string();
46-
let target_branch = match resolve_target_branch(&worktree_name, track) {
59+
let target_branch = match resolve_target_branch(&worktree.branch_name, track) {
4760
Ok(branch) => branch,
4861
Err(e) => {
4962
eprintln!("{} {}", "Error:".red(), e);
@@ -58,10 +71,10 @@ pub fn run(name: Option<&str>, track: Option<&str>) {
5871
match add_worktree(&repo, &worktree_path_str, &target_branch, true, track) {
5972
Ok(()) => is_new_branch = true,
6073
Err(new_err) => {
61-
let worktree_and_branch = if target_branch == worktree_name {
62-
worktree_name.clone()
74+
let worktree_and_branch = if target_branch == worktree.directory_name {
75+
worktree.directory_name.clone()
6376
} else {
64-
format!("{} (branch: {})", worktree_name, target_branch)
77+
format!("{} (branch: {})", worktree.directory_name, target_branch)
6578
};
6679
eprintln!(
6780
"{} Failed to create worktree for '{}':\n As existing branch: {}\n As new branch: {}",
@@ -75,10 +88,10 @@ pub fn run(name: Option<&str>, track: Option<&str>) {
7588
}
7689
}
7790

78-
let worktree_and_branch = if target_branch == worktree_name {
79-
worktree_name.clone()
91+
let worktree_and_branch = if target_branch == worktree.directory_name {
92+
worktree.directory_name.clone()
8093
} else {
81-
format!("{} (branch: {})", worktree_name, target_branch)
94+
format!("{} (branch: {})", worktree.directory_name, target_branch)
8295
};
8396
if is_new_branch {
8497
println!(
@@ -95,14 +108,6 @@ pub fn run(name: Option<&str>, track: Option<&str>) {
95108
}
96109
println!("{}", format!("Path: {}", worktree_path_str).dimmed());
97110

98-
let repo_config = match read_repo_config(project_root) {
99-
Ok(config) => config,
100-
Err(e) => {
101-
eprintln!("{} {}", "Warning:".yellow(), e);
102-
return;
103-
}
104-
};
105-
106111
let commands = match repo_config.bootstrap {
107112
Some(bootstrap) if !bootstrap.commands.is_empty() => bootstrap.commands,
108113
_ => return,
@@ -140,22 +145,31 @@ pub fn run(name: Option<&str>, track: Option<&str>) {
140145
}
141146
}
142147

143-
fn resolve_worktree_name(
148+
fn resolve_worktree_spec(
144149
provided_name: Option<&str>,
145150
repo: &RepoContext,
146151
project_root: &Path,
147-
) -> Result<String, String> {
152+
repo_config: &RepoConfig,
153+
) -> Result<WorktreeSpec, String> {
148154
if let Some(name) = provided_name {
149-
return Ok(name.to_string());
155+
return Ok(WorktreeSpec {
156+
directory_name: name.to_string(),
157+
branch_name: name.to_string(),
158+
});
150159
}
151160

152-
choose_default_worktree_name(repo, project_root)
161+
choose_default_worktree_spec(repo, project_root, repo_config.branch_prefix.as_deref())
153162
}
154163

155-
fn choose_default_worktree_name(repo: &RepoContext, project_root: &Path) -> Result<String, String> {
164+
fn choose_default_worktree_spec(
165+
repo: &RepoContext,
166+
project_root: &Path,
167+
branch_prefix: Option<&str>,
168+
) -> Result<WorktreeSpec, String> {
156169
let seed = default_worktree_name_seed();
157170
for attempt in 0..DEFAULT_WORKTREE_NAME_ATTEMPTS {
158-
let candidate = generate_default_worktree_name(seed, attempt);
171+
let generated_name = generate_default_worktree_name(seed, attempt);
172+
let candidate = generated_worktree_spec(branch_prefix, &generated_name);
159173
if is_name_available(repo, project_root, &candidate) {
160174
return Ok(candidate);
161175
}
@@ -167,12 +181,30 @@ fn choose_default_worktree_name(repo: &RepoContext, project_root: &Path) -> Resu
167181
)
168182
}
169183

170-
fn is_name_available(repo: &RepoContext, project_root: &Path, candidate: &str) -> bool {
171-
if branch_exists(repo, candidate) {
184+
fn is_name_available(repo: &RepoContext, project_root: &Path, candidate: &WorktreeSpec) -> bool {
185+
if branch_exists(repo, &candidate.branch_name) {
172186
return false;
173187
}
174188

175-
!project_root.join(candidate).exists()
189+
!project_root.join(&candidate.directory_name).exists()
190+
}
191+
192+
fn generated_worktree_spec(branch_prefix: Option<&str>, generated_name: &str) -> WorktreeSpec {
193+
WorktreeSpec {
194+
directory_name: generated_name.to_string(),
195+
branch_name: apply_branch_prefix(branch_prefix, generated_name),
196+
}
197+
}
198+
199+
fn apply_branch_prefix(branch_prefix: Option<&str>, generated_name: &str) -> String {
200+
let Some(prefix) = branch_prefix
201+
.map(trim_trailing_branch_slashes)
202+
.filter(|prefix| !prefix.is_empty())
203+
else {
204+
return generated_name.to_string();
205+
};
206+
207+
format!("{}/{}", prefix, generated_name)
176208
}
177209

178210
fn resolve_target_branch(name: &str, track: Option<&str>) -> Result<String, String> {
@@ -504,4 +536,36 @@ mod tests {
504536
let result = resolve_target_branch("foo", Some("refs/heads/feature/new-ui"));
505537
assert!(result.is_err());
506538
}
539+
540+
#[test]
541+
fn apply_branch_prefix_adds_separator_when_configured() {
542+
let prefixed = apply_branch_prefix(Some("safia"), "quiet-meadow");
543+
assert_eq!(prefixed, "safia/quiet-meadow");
544+
}
545+
546+
#[test]
547+
fn apply_branch_prefix_trims_whitespace_and_trailing_slashes() {
548+
let prefixed = apply_branch_prefix(Some(" teams/safia/ "), "quiet-meadow");
549+
assert_eq!(prefixed, "teams/safia/quiet-meadow");
550+
}
551+
552+
#[test]
553+
fn apply_branch_prefix_ignores_empty_prefix() {
554+
let unprefixed = apply_branch_prefix(Some(" / "), "quiet-meadow");
555+
assert_eq!(unprefixed, "quiet-meadow");
556+
}
557+
558+
#[test]
559+
fn generated_worktree_spec_applies_prefix_only_to_branch_name() {
560+
let spec = generated_worktree_spec(Some("safia"), "quiet-meadow");
561+
assert_eq!(spec.directory_name, "quiet-meadow");
562+
assert_eq!(spec.branch_name, "safia/quiet-meadow");
563+
}
564+
565+
#[test]
566+
fn generated_worktree_spec_without_prefix_keeps_names_equal() {
567+
let spec = generated_worktree_spec(None, "quiet-meadow");
568+
assert_eq!(spec.directory_name, "quiet-meadow");
569+
assert_eq!(spec.branch_name, "quiet-meadow");
570+
}
507571
}

src/utils.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ pub struct RepoBootstrapConfig {
6161
pub struct RepoConfig {
6262
#[serde(default)]
6363
pub bootstrap: Option<RepoBootstrapConfig>,
64+
#[serde(rename = "branchPrefix", default)]
65+
pub branch_prefix: Option<String>,
6466
}
6567

6668
/// Read the grove config file.
@@ -698,6 +700,7 @@ mod tests {
698700
let dir = make_temp_dir("repo-config-missing");
699701
let config = read_repo_config(&dir).unwrap();
700702
assert!(config.bootstrap.is_none());
703+
assert!(config.branch_prefix.is_none());
701704
let _ = fs::remove_dir_all(dir);
702705
}
703706

@@ -707,6 +710,7 @@ mod tests {
707710
fs::write(
708711
dir.join(".groverc"),
709712
r#"{
713+
"branchPrefix": "safia",
710714
"bootstrap": {
711715
"commands": [
712716
{ "program": "npm", "args": ["install"] },
@@ -718,6 +722,7 @@ mod tests {
718722
.unwrap();
719723

720724
let config = read_repo_config(&dir).unwrap();
725+
assert_eq!(config.branch_prefix.as_deref(), Some("safia"));
721726
let bootstrap = config.bootstrap.unwrap();
722727
assert_eq!(bootstrap.commands.len(), 2);
723728
assert_eq!(bootstrap.commands[0].program, "npm");
@@ -727,6 +732,23 @@ mod tests {
727732
let _ = fs::remove_dir_all(dir);
728733
}
729734

735+
#[test]
736+
fn read_repo_config_parses_branch_prefix_without_bootstrap() {
737+
let dir = make_temp_dir("repo-config-prefix-only");
738+
fs::write(
739+
dir.join(".groverc"),
740+
r#"{
741+
"branchPrefix": "teams/safia"
742+
}"#,
743+
)
744+
.unwrap();
745+
746+
let config = read_repo_config(&dir).unwrap();
747+
assert_eq!(config.branch_prefix.as_deref(), Some("teams/safia"));
748+
assert!(config.bootstrap.is_none());
749+
let _ = fs::remove_dir_all(dir);
750+
}
751+
730752
#[test]
731753
fn read_repo_config_rejects_string_commands_schema() {
732754
let dir = make_temp_dir("repo-config-string-schema");

0 commit comments

Comments
 (0)