Skip to content

Commit 4e2f737

Browse files
fix: enforce alphanumeric branchprefix values (#76)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent a7d46e3 commit 4e2f737

4 files changed

Lines changed: 100 additions & 26 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ grove add
8989
# Example generated name: quiet-meadow
9090
# If .groverc sets "branchPrefix": "safia", example: safia/quiet-meadow
9191
# Directory remains: quiet-meadow
92+
# branchPrefix only accepts alphanumeric characters
9293
```
9394

9495
Track a remote branch:
@@ -113,7 +114,7 @@ Bootstrap a newly created worktree with project-scoped commands:
113114

114115
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`).
115116

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+
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. `branchPrefix` must be alphanumeric only (letters and numbers). The worktree directory keeps the generated base name.
117118

118119
When `grove add` creates a worktree, it runs each bootstrap command in order inside that new worktree directory.
119120

site/index.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,8 @@ <h3>Add a new worktree</h3>
801801
<pre><code>grove add
802802
# Example generated name: quiet-meadow
803803
# If .groverc sets "branchPrefix": "safia", example: safia/quiet-meadow
804-
# Directory remains: quiet-meadow</code></pre>
804+
# Directory remains: quiet-meadow
805+
# branchPrefix only accepts alphanumeric characters</code></pre>
805806
<p>With tracking for a remote branch:</p>
806807
<pre><code>grove add feature-branch --track origin/feature-branch</code></pre>
807808
<p>Optional bootstrap commands from <code>.groverc</code> run in the new worktree:</p>
@@ -815,7 +816,7 @@ <h3>Add a new worktree</h3>
815816
}
816817
}</code></pre>
817818
<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>
819+
<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. <code>branchPrefix</code> must be alphanumeric only (letters and numbers). The worktree directory keeps the generated base name.</p>
819820
<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>
820821
</div>
821822

src/commands/add.rs

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::git::{
77
};
88
use crate::utils::{
99
default_worktree_name_seed, generate_default_worktree_name, read_repo_config,
10-
trim_trailing_branch_slashes, BootstrapCommand, RepoConfig, DEFAULT_WORKTREE_NAME_ATTEMPTS,
10+
sanitize_branch_prefix, BootstrapCommand, RepoConfig, DEFAULT_WORKTREE_NAME_ATTEMPTS,
1111
};
1212

1313
#[derive(Debug)]
@@ -169,7 +169,7 @@ fn choose_default_worktree_spec(
169169
let seed = default_worktree_name_seed();
170170
for attempt in 0..DEFAULT_WORKTREE_NAME_ATTEMPTS {
171171
let generated_name = generate_default_worktree_name(seed, attempt);
172-
let candidate = generated_worktree_spec(branch_prefix, &generated_name);
172+
let candidate = generated_worktree_spec(branch_prefix, &generated_name)?;
173173
if is_name_available(repo, project_root, &candidate) {
174174
return Ok(candidate);
175175
}
@@ -189,22 +189,28 @@ fn is_name_available(repo: &RepoContext, project_root: &Path, candidate: &Worktr
189189
!project_root.join(&candidate.directory_name).exists()
190190
}
191191

192-
fn generated_worktree_spec(branch_prefix: Option<&str>, generated_name: &str) -> WorktreeSpec {
193-
WorktreeSpec {
192+
fn generated_worktree_spec(
193+
branch_prefix: Option<&str>,
194+
generated_name: &str,
195+
) -> Result<WorktreeSpec, String> {
196+
Ok(WorktreeSpec {
194197
directory_name: generated_name.to_string(),
195-
branch_name: apply_branch_prefix(branch_prefix, generated_name),
196-
}
198+
branch_name: apply_branch_prefix(branch_prefix, generated_name)?,
199+
})
197200
}
198201

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();
202+
fn apply_branch_prefix(
203+
branch_prefix: Option<&str>,
204+
generated_name: &str,
205+
) -> Result<String, String> {
206+
let Some(prefix) = branch_prefix else {
207+
return Ok(generated_name.to_string());
208+
};
209+
let Some(prefix) = sanitize_branch_prefix(prefix)? else {
210+
return Ok(generated_name.to_string());
205211
};
206212

207-
format!("{}/{}", prefix, generated_name)
213+
Ok(format!("{}/{}", prefix, generated_name))
208214
}
209215

210216
fn resolve_target_branch(name: &str, track: Option<&str>) -> Result<String, String> {
@@ -539,32 +545,38 @@ mod tests {
539545

540546
#[test]
541547
fn apply_branch_prefix_adds_separator_when_configured() {
542-
let prefixed = apply_branch_prefix(Some("safia"), "quiet-meadow");
548+
let prefixed = apply_branch_prefix(Some("safia"), "quiet-meadow").unwrap();
543549
assert_eq!(prefixed, "safia/quiet-meadow");
544550
}
545551

546552
#[test]
547553
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");
554+
let prefixed = apply_branch_prefix(Some(" safia123/ "), "quiet-meadow").unwrap();
555+
assert_eq!(prefixed, "safia123/quiet-meadow");
550556
}
551557

552558
#[test]
553559
fn apply_branch_prefix_ignores_empty_prefix() {
554-
let unprefixed = apply_branch_prefix(Some(" / "), "quiet-meadow");
560+
let unprefixed = apply_branch_prefix(Some(" / "), "quiet-meadow").unwrap();
555561
assert_eq!(unprefixed, "quiet-meadow");
556562
}
557563

564+
#[test]
565+
fn apply_branch_prefix_rejects_non_alphanumeric_prefix() {
566+
let err = apply_branch_prefix(Some("teams/safia"), "quiet-meadow").unwrap_err();
567+
assert!(err.contains("alphanumeric"));
568+
}
569+
558570
#[test]
559571
fn generated_worktree_spec_applies_prefix_only_to_branch_name() {
560-
let spec = generated_worktree_spec(Some("safia"), "quiet-meadow");
572+
let spec = generated_worktree_spec(Some("safia"), "quiet-meadow").unwrap();
561573
assert_eq!(spec.directory_name, "quiet-meadow");
562574
assert_eq!(spec.branch_name, "safia/quiet-meadow");
563575
}
564576

565577
#[test]
566578
fn generated_worktree_spec_without_prefix_keeps_names_equal() {
567-
let spec = generated_worktree_spec(None, "quiet-meadow");
579+
let spec = generated_worktree_spec(None, "quiet-meadow").unwrap();
568580
assert_eq!(spec.directory_name, "quiet-meadow");
569581
assert_eq!(spec.branch_name, "quiet-meadow");
570582
}

src/utils.rs

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,15 @@ pub fn read_repo_config(project_root: &Path) -> Result<RepoConfig, String> {
109109
}
110110
};
111111

112-
serde_json::from_str(&content)
113-
.map_err(|e| format!("Invalid repo config at {}: {}", path.display(), e))
112+
let mut config: RepoConfig = serde_json::from_str(&content)
113+
.map_err(|e| format!("Invalid repo config at {}: {}", path.display(), e))?;
114+
115+
if let Some(prefix) = config.branch_prefix.as_deref() {
116+
config.branch_prefix = sanitize_branch_prefix(prefix)
117+
.map_err(|e| format!("Invalid repo config at {}: {}", path.display(), e))?;
118+
}
119+
120+
Ok(config)
114121
}
115122

116123
// ============================================================================
@@ -198,6 +205,21 @@ pub fn trim_trailing_branch_slashes(value: &str) -> &str {
198205
value.trim().trim_end_matches('/')
199206
}
200207

208+
/// Sanitize a branchPrefix value from config.
209+
/// Returns None for empty values after trimming.
210+
pub fn sanitize_branch_prefix(value: &str) -> Result<Option<String>, String> {
211+
let normalized = trim_trailing_branch_slashes(value);
212+
if normalized.is_empty() {
213+
return Ok(None);
214+
}
215+
216+
if normalized.chars().all(|c| c.is_ascii_alphanumeric()) {
217+
return Ok(Some(normalized.to_string()));
218+
}
219+
220+
Err("Invalid branchPrefix: must contain only alphanumeric characters".to_string())
221+
}
222+
201223
pub const DEFAULT_WORKTREE_NAME_ATTEMPTS: u64 = 64;
202224
const DEFAULT_WORKTREE_NAME_ADJECTIVES: &[&str] = &[
203225
"amber", "autumn", "brisk", "calm", "cedar", "clear", "cobalt", "cosmic", "dawn", "deep",
@@ -738,17 +760,33 @@ mod tests {
738760
fs::write(
739761
dir.join(".groverc"),
740762
r#"{
741-
"branchPrefix": "teams/safia"
763+
"branchPrefix": " safia123/ "
742764
}"#,
743765
)
744766
.unwrap();
745767

746768
let config = read_repo_config(&dir).unwrap();
747-
assert_eq!(config.branch_prefix.as_deref(), Some("teams/safia"));
769+
assert_eq!(config.branch_prefix.as_deref(), Some("safia123"));
748770
assert!(config.bootstrap.is_none());
749771
let _ = fs::remove_dir_all(dir);
750772
}
751773

774+
#[test]
775+
fn read_repo_config_rejects_non_alphanumeric_branch_prefix() {
776+
let dir = make_temp_dir("repo-config-invalid-prefix");
777+
fs::write(
778+
dir.join(".groverc"),
779+
r#"{
780+
"branchPrefix": "teams/safia"
781+
}"#,
782+
)
783+
.unwrap();
784+
785+
let err = read_repo_config(&dir).unwrap_err();
786+
assert!(err.contains("branchPrefix"));
787+
let _ = fs::remove_dir_all(dir);
788+
}
789+
752790
#[test]
753791
fn read_repo_config_rejects_string_commands_schema() {
754792
let dir = make_temp_dir("repo-config-string-schema");
@@ -767,6 +805,28 @@ mod tests {
767805
let _ = fs::remove_dir_all(dir);
768806
}
769807

808+
#[test]
809+
fn sanitize_branch_prefix_accepts_alphanumeric_value() {
810+
assert_eq!(
811+
sanitize_branch_prefix(" safia123 ").unwrap(),
812+
Some("safia123".to_string())
813+
);
814+
}
815+
816+
#[test]
817+
fn sanitize_branch_prefix_trims_trailing_slashes() {
818+
assert_eq!(
819+
sanitize_branch_prefix("safia123/").unwrap(),
820+
Some("safia123".to_string())
821+
);
822+
}
823+
824+
#[test]
825+
fn sanitize_branch_prefix_rejects_non_alphanumeric_value() {
826+
let err = sanitize_branch_prefix("team/safia").unwrap_err();
827+
assert!(err.contains("alphanumeric"));
828+
}
829+
770830
// --- extractRepoName tests ---
771831

772832
#[test]

0 commit comments

Comments
 (0)