Skip to content

Commit 52af30f

Browse files
fix: harden add tracking and config validation (#77)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 5fb0067 commit 52af30f

File tree

7 files changed

+153
-28
lines changed

7 files changed

+153
-28
lines changed

src/commands/add.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ use std::path::{Path, PathBuf};
33
use std::process::{Command, Stdio};
44

55
use crate::git::{
6-
add_worktree, branch_exists, discover_repo, project_root, tracked_branch_name, RepoContext,
6+
add_worktree, branch_exists, discover_repo, normalize_tracking_reference_input, project_root,
7+
tracked_branch_name, RepoContext,
78
};
89
use crate::utils::{
910
default_worktree_name_seed, generate_default_worktree_name, read_repo_config,
@@ -36,8 +37,8 @@ pub fn run(name: Option<&str>, track: Option<&str>) {
3637
let repo_config = match read_repo_config(project_root) {
3738
Ok(config) => config,
3839
Err(e) => {
39-
eprintln!("{} {}", "Warning:".yellow(), e);
40-
RepoConfig::default()
40+
eprintln!("{} {}", "Error:".red(), e);
41+
std::process::exit(1);
4142
}
4243
};
4344
let worktree = match resolve_worktree_spec(name, &repo, project_root, &repo_config) {
@@ -215,12 +216,17 @@ fn apply_branch_prefix(
215216

216217
fn resolve_target_branch(name: &str, track: Option<&str>) -> Result<String, String> {
217218
match track {
218-
Some(track_ref) => tracked_branch_name(track_ref).ok_or_else(|| {
219-
format!(
220-
"Invalid tracking branch '{}'. Use '<remote>/<branch>' or 'refs/remotes/<remote>/<branch>'.",
221-
track_ref
222-
)
223-
}).map(str::to_string),
219+
Some(track_ref) => {
220+
let normalized = normalize_tracking_reference_input(track_ref)?;
221+
tracked_branch_name(&normalized)
222+
.ok_or_else(|| {
223+
format!(
224+
"Invalid tracking branch '{}'. Use '<remote>/<branch>' or 'refs/remotes/<remote>/<branch>'.",
225+
track_ref
226+
)
227+
})
228+
.map(str::to_string)
229+
}
224230
None => Ok(name.to_string()),
225231
}
226232
}
@@ -537,12 +543,24 @@ mod tests {
537543
assert_eq!(result, "feature/new-ui");
538544
}
539545

546+
#[test]
547+
fn resolve_target_branch_trims_tracking_ref_trailing_slash() {
548+
let result = resolve_target_branch("foo", Some("origin/feature/new-ui/")).unwrap();
549+
assert_eq!(result, "feature/new-ui");
550+
}
551+
540552
#[test]
541553
fn resolve_target_branch_rejects_non_remote_ref() {
542554
let result = resolve_target_branch("foo", Some("refs/heads/feature/new-ui"));
543555
assert!(result.is_err());
544556
}
545557

558+
#[test]
559+
fn resolve_target_branch_rejects_malformed_tracking_ref() {
560+
let result = resolve_target_branch("foo", Some("origin/feature//new-ui"));
561+
assert!(result.is_err());
562+
}
563+
546564
#[test]
547565
fn apply_branch_prefix_adds_separator_when_configured() {
548566
let prefixed = apply_branch_prefix(Some("safia"), "quiet-meadow").unwrap();

src/commands/go.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@ pub fn run(name: Option<&str>, path_only: bool) {
1616
&& !atty::is(atty::Stream::Stdin)
1717
{
1818
eprintln!(
19-
"{} {}",
20-
"Error:".red(),
21-
"Interactive selection requires a TTY. Provide a branch name when using --path-only."
19+
"{} Interactive selection requires a TTY. Provide a branch name when using --path-only.",
20+
"Error:".red()
2221
);
2322
std::process::exit(1);
2423
}

src/commands/init.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,5 @@ pub fn run(git_url: &str) {
6868
println!();
6969
println!("{}", "Next steps:".bold());
7070
println!(" {} {}", "cd".dimmed(), bare_repo_dir);
71-
println!(" {} {}", "grove add".dimmed(), "<branch-name>");
71+
println!(" {} <branch-name>", "grove add".dimmed());
7272
}

src/commands/prune.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,13 @@ pub fn run(dry_run: bool, force: bool, base: Option<&str>, older_than: Option<&s
105105
return;
106106
}
107107

108-
if older_than.is_some() {
108+
if let Some(duration) = older_than {
109109
println!(
110110
"{}",
111111
format!(
112112
"Found {} worktree(s) older than {}:",
113113
candidates.len(),
114-
older_than.unwrap()
114+
duration
115115
)
116116
.green()
117117
);

src/git/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod worktree_manager;
22

33
pub use worktree_manager::{
44
add_worktree, branch_exists, clone_bare_repository, discover_repo, find_worktree_by_name,
5-
get_default_branch, is_branch_merged, list_worktrees, project_root, remove_worktree,
6-
remove_worktrees, repo_path, sync_branch, tracked_branch_name, RepoContext, DETACHED_HEAD,
5+
get_default_branch, is_branch_merged, list_worktrees, normalize_tracking_reference_input,
6+
project_root, remove_worktree, remove_worktrees, repo_path, sync_branch, tracked_branch_name,
7+
RepoContext, DETACHED_HEAD,
78
};

src/git/worktree_manager.rs

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,12 @@ pub fn add_worktree(
160160
create_branch: bool,
161161
track: Option<&str>,
162162
) -> Result<(), String> {
163-
if let Some(track_branch) = track {
163+
let normalized_track = match track {
164+
Some(track_branch) => Some(normalize_tracking_reference_input(track_branch)?),
165+
None => None,
166+
};
167+
168+
if let Some(track_branch) = normalized_track.as_deref() {
164169
ensure_tracking_reference(context, track_branch)?;
165170
}
166171

@@ -169,11 +174,11 @@ pub fn add_worktree(
169174
normalized_worktree_path.as_str(),
170175
branch_name,
171176
create_branch,
172-
track,
177+
normalized_track.as_deref(),
173178
);
174179

175180
git_raw(context, &args).map_err(|e| format!("Failed to add worktree: {}", e))?;
176-
if let Some(track_branch) = track {
181+
if let Some(track_branch) = normalized_track.as_deref() {
177182
set_branch_upstream(context, branch_name, track_branch)?;
178183
}
179184
Ok(())
@@ -240,6 +245,18 @@ fn reference_exists(context: &RepoContext, reference: &str) -> bool {
240245
git_raw(context, &["rev-parse", "--verify", reference]).is_ok()
241246
}
242247

248+
pub fn normalize_tracking_reference_input(reference: &str) -> Result<String, String> {
249+
let normalized = trim_trailing_branch_slashes(reference);
250+
let (remote, branch) = parse_remote_tracking_reference(normalized)
251+
.ok_or_else(|| invalid_tracking_reference(reference))?;
252+
253+
if !is_valid_ref_component(remote) || !is_valid_ref_path(branch) {
254+
return Err(invalid_tracking_reference(reference));
255+
}
256+
257+
Ok(format!("{}/{}", remote, branch))
258+
}
259+
243260
fn parse_remote_tracking_reference(reference: &str) -> Option<(&str, &str)> {
244261
let normalized = if let Some(rest) = reference.strip_prefix("refs/remotes/") {
245262
rest
@@ -261,6 +278,34 @@ pub fn tracked_branch_name(reference: &str) -> Option<&str> {
261278
parse_remote_tracking_reference(reference).map(|(_, branch)| branch)
262279
}
263280

281+
fn invalid_tracking_reference(reference: &str) -> String {
282+
format!(
283+
"Invalid tracking branch '{}'. Use '<remote>/<branch>' or 'refs/remotes/<remote>/<branch>'.",
284+
reference
285+
)
286+
}
287+
288+
fn is_valid_ref_path(path: &str) -> bool {
289+
!path.is_empty()
290+
&& !path.contains("@{")
291+
&& !path.ends_with('.')
292+
&& path.split('/').all(is_valid_ref_component)
293+
}
294+
295+
fn is_valid_ref_component(component: &str) -> bool {
296+
!component.is_empty()
297+
&& component != "."
298+
&& component != ".."
299+
&& !component.starts_with('.')
300+
&& !component.ends_with(".lock")
301+
&& !component.contains("..")
302+
&& !component.chars().any(contains_invalid_ref_char)
303+
}
304+
305+
fn contains_invalid_ref_char(ch: char) -> bool {
306+
ch.is_ascii_control() || matches!(ch, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\')
307+
}
308+
264309
fn set_branch_upstream(
265310
context: &RepoContext,
266311
branch_name: &str,
@@ -407,22 +452,22 @@ fn parse_worktree_lines(output: &str) -> Vec<PartialWorktree> {
407452
};
408453

409454
for line in output.trim().lines() {
410-
if line.starts_with("worktree ") {
455+
if let Some(path) = line.strip_prefix("worktree ") {
411456
if current.path.is_some() && !current.is_bare {
412457
worktrees.push(current);
413458
}
414459
current = PartialWorktree {
415-
path: Some(line[9..].to_string()),
460+
path: Some(path.to_string()),
416461
head: None,
417462
branch: None,
418463
is_locked: false,
419464
is_prunable: false,
420465
is_bare: false,
421466
};
422-
} else if line.starts_with("HEAD ") {
423-
current.head = Some(line[5..].to_string());
424-
} else if line.starts_with("branch ") {
425-
current.branch = Some(line[7..].replace("refs/heads/", ""));
467+
} else if let Some(head) = line.strip_prefix("HEAD ") {
468+
current.head = Some(head.to_string());
469+
} else if let Some(branch) = line.strip_prefix("branch ") {
470+
current.branch = Some(branch.replace("refs/heads/", ""));
426471
} else if line == "detached" {
427472
current.branch = Some(DETACHED_HEAD.to_string());
428473
} else if line == "locked" {
@@ -720,6 +765,32 @@ mod tests {
720765
assert_eq!(parse_remote_tracking_reference("origin"), None);
721766
}
722767

768+
#[test]
769+
fn normalize_tracking_reference_input_trims_trailing_slash() {
770+
assert_eq!(
771+
normalize_tracking_reference_input("origin/feature/test/").unwrap(),
772+
"origin/feature/test"
773+
);
774+
}
775+
776+
#[test]
777+
fn normalize_tracking_reference_input_normalizes_full_ref() {
778+
assert_eq!(
779+
normalize_tracking_reference_input("refs/remotes/origin/feature/test/").unwrap(),
780+
"origin/feature/test"
781+
);
782+
}
783+
784+
#[test]
785+
fn normalize_tracking_reference_input_rejects_empty_branch() {
786+
assert!(normalize_tracking_reference_input("origin/").is_err());
787+
}
788+
789+
#[test]
790+
fn normalize_tracking_reference_input_rejects_empty_path_segment() {
791+
assert!(normalize_tracking_reference_input("origin/feature//test").is_err());
792+
}
793+
723794
#[test]
724795
fn tracked_branch_name_returns_remote_branch_part() {
725796
assert_eq!(

src/main.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod git;
88
mod models;
99
mod utils;
1010

11+
use crate::git::normalize_tracking_reference_input;
1112
use crate::utils::{is_valid_git_url, parse_duration, trim_trailing_branch_slashes};
1213

1314
const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -62,6 +63,10 @@ fn validate_duration(value: &str) -> Result<String, String> {
6263
parse_duration(value).map(|_| value.to_string())
6364
}
6465

66+
fn validate_tracking_reference(value: &str) -> Result<String, String> {
67+
normalize_tracking_reference_input(value)
68+
}
69+
6570
#[derive(Parser)]
6671
#[command(name = "grove", about = "Grove is a Git worktree management tool", version = VERSION)]
6772
struct Cli {
@@ -77,7 +82,7 @@ enum Commands {
7782
#[arg(value_parser = validate_branch_name)]
7883
name: Option<String>,
7984
/// Set up tracking for the specified remote branch
80-
#[arg(short = 't', long = "track")]
85+
#[arg(short = 't', long = "track", value_parser = validate_tracking_reference)]
8186
track: Option<String>,
8287
},
8388
/// Navigate to a worktree by branch name
@@ -243,7 +248,7 @@ fn main() {
243248

244249
#[cfg(test)]
245250
mod tests {
246-
use super::{validate_branch_name, Cli, Commands};
251+
use super::{validate_branch_name, validate_tracking_reference, Cli, Commands};
247252
use clap::Parser;
248253

249254
#[test]
@@ -259,6 +264,19 @@ mod tests {
259264
assert!(validate_branch_name("///").is_err());
260265
}
261266

267+
#[test]
268+
fn validate_tracking_reference_trims_trailing_slashes() {
269+
assert_eq!(
270+
validate_tracking_reference("origin/feature/my-branch///").unwrap(),
271+
"origin/feature/my-branch"
272+
);
273+
}
274+
275+
#[test]
276+
fn validate_tracking_reference_rejects_empty_path_segments() {
277+
assert!(validate_tracking_reference("origin/feature//my-branch").is_err());
278+
}
279+
262280
#[test]
263281
fn add_command_allows_omitted_name() {
264282
let cli = Cli::try_parse_from(["grove", "add"]).unwrap();
@@ -289,4 +307,22 @@ mod tests {
289307
_ => panic!("expected add command"),
290308
}
291309
}
310+
311+
#[test]
312+
fn add_command_normalizes_track_input() {
313+
let cli = Cli::try_parse_from([
314+
"grove",
315+
"add",
316+
"feature/new-worktree",
317+
"--track",
318+
"origin/main/",
319+
])
320+
.unwrap();
321+
match cli.command {
322+
Some(Commands::Add { track, .. }) => {
323+
assert_eq!(track.as_deref(), Some("origin/main"));
324+
}
325+
_ => panic!("expected add command"),
326+
}
327+
}
292328
}

0 commit comments

Comments
 (0)