Skip to content

Commit 9c25e3f

Browse files
committed
feat: auto-detect repository from git remote
Add automatic repository detection from git remote URL, eliminating the need to pass -r flag or set GHSTACK_TARGET_REPOSITORY when running from inside a git repository. Changes: - Add parse_github_remote_url() to parse SSH/HTTPS URLs (including GitHub Enterprise) - Add detect_repo_from_remote() to extract owner/repo from git remote - Add resolve_repository() helper with fallback chain: -r flag -> env var -> auto-detect - Add --origin/-o flag to specify which remote to use (defaults to 'origin') - Update annotate, log, autorebase, and land commands to use auto-detection - Add unit tests for URL parsing
1 parent 1422f50 commit 9c25e3f

2 files changed

Lines changed: 185 additions & 47 deletions

File tree

src/main.rs

Lines changed: 79 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,20 @@ fn clap<'a, 'b>() -> App<'a, 'b> {
4747
.takes_value(false)
4848
.help("Use shields.io badges for PR status (requires public repo visibility)");
4949

50+
let origin = Arg::with_name("origin")
51+
.long("origin")
52+
.short("o")
53+
.takes_value(true)
54+
.default_value("origin")
55+
.help("Name of the git remote to detect repository from (default: origin)");
56+
5057
let annotate = SubCommand::with_name("annotate")
5158
.about("Annotate the descriptions of all PRs in a stack with metadata about all PRs in the stack")
5259
.setting(AppSettings::ArgRequiredElseHelp)
5360
.arg(identifier.clone())
5461
.arg(exclude.clone())
5562
.arg(repository.clone())
63+
.arg(origin.clone())
5664
.arg(ci.clone())
5765
.arg(prefix.clone())
5866
.arg(badges.clone())
@@ -74,6 +82,7 @@ fn clap<'a, 'b>() -> App<'a, 'b> {
7482
.arg(identifier.clone().index(2))
7583
.arg(exclude.clone())
7684
.arg(repository.clone())
85+
.arg(origin.clone())
7786
.arg(
7887
Arg::with_name("project")
7988
.long("project")
@@ -129,6 +138,7 @@ fn clap<'a, 'b>() -> App<'a, 'b> {
129138
.arg(identifier.clone())
130139
.arg(exclude.clone())
131140
.arg(repository.clone())
141+
.arg(origin.clone())
132142
.arg(
133143
Arg::with_name("no-approval")
134144
.long("no-approval")
@@ -209,6 +219,45 @@ fn get_excluded(m: &ArgMatches) -> Vec<String> {
209219
}
210220
}
211221

222+
/// Resolve the repository to use, with fallback chain:
223+
/// 1. -r flag (explicit override)
224+
/// 2. GHSTACK_TARGET_REPOSITORY env var
225+
/// 3. Auto-detect from git remote
226+
fn resolve_repository(
227+
arg_value: Option<&str>,
228+
env_value: &str,
229+
remote_name: &str,
230+
) -> Result<String, String> {
231+
// Priority 1: Explicit -r flag
232+
if let Some(repo) = arg_value {
233+
if !repo.is_empty() {
234+
return Ok(repo.to_string());
235+
}
236+
}
237+
238+
// Priority 2: Environment variable
239+
if !env_value.is_empty() {
240+
return Ok(env_value.to_string());
241+
}
242+
243+
// Priority 3: Auto-detect from git remote
244+
if let Some(repo) = tree::detect_repo_from_remote(remote_name) {
245+
eprintln!(
246+
"Detected repository: {} (from {} remote)",
247+
style(&repo).cyan(),
248+
remote_name
249+
);
250+
return Ok(repo);
251+
}
252+
253+
Err(format!(
254+
"Could not determine repository. Either:\n \
255+
- Run from inside a git repo with a GitHub remote\n \
256+
- Set GHSTACK_TARGET_REPOSITORY environment variable\n \
257+
- Use the -r flag"
258+
))
259+
}
260+
212261
fn remove_title_prefixes(title: String, prefix: &str) -> String {
213262
let regex = Regex::new(&format!("[{}]", prefix).to_string()).unwrap();
214263
regex.replace_all(&title, "").into_owned()
@@ -231,34 +280,29 @@ async fn main() -> Result<(), Box<dyn Error>> {
231280
let prefix = regex::escape(prefix);
232281
// if ci flag is set, set ci to true
233282
let ci = m.is_present("ci");
234-
// replace it with the -r argument value if set
235-
let repository = m.value_of("repository").unwrap_or(&repository);
236-
// if repository is still unset, throw an error
237-
if repository.is_empty() {
238-
let error = format!(
239-
"Invalid target repository {repo}. You must pass a repository with the -r flag or set GHSTACK_TARGET_REPOSITORY", repo = repository
240-
);
241-
panic!("{}", error);
242-
}
283+
// resolve repository with fallback chain
284+
let remote_name = m.value_of("origin").unwrap_or("origin");
285+
let repository = resolve_repository(m.value_of("repository"), &repository, remote_name)
286+
.unwrap_or_else(|e| panic!("{}", e));
243287

244288
let identifier = remove_title_prefixes(identifier.to_string(), &prefix);
245289

246290
println!(
247291
"Searching for {} identifier in {} repo",
248292
style(&identifier).bold(),
249-
style(repository).bold()
293+
style(&repository).bold()
250294
);
251295

252296
let stack =
253-
build_pr_stack_for_repo(&identifier, repository, &credentials, get_excluded(m))
297+
build_pr_stack_for_repo(&identifier, &repository, &credentials, get_excluded(m))
254298
.await?;
255299

256300
let use_badges = m.is_present("badges");
257301
let table = markdown::build_table(
258302
&stack,
259303
&identifier,
260304
m.value_of("prelude"),
261-
repository,
305+
&repository,
262306
use_badges,
263307
);
264308

@@ -288,22 +332,18 @@ async fn main() -> Result<(), Box<dyn Error>> {
288332
}
289333
};
290334

291-
// replace it with the -r argument value if set
292-
let repository = m.value_of("repository").unwrap_or(&repository);
293-
// if repository is still unset, throw an error
294-
if repository.is_empty() {
295-
panic!(
296-
"You must pass a repository with the -r flag or set GHSTACK_TARGET_REPOSITORY"
297-
);
298-
}
335+
// resolve repository with fallback chain
336+
let remote_name = m.value_of("origin").unwrap_or("origin");
337+
let repository = resolve_repository(m.value_of("repository"), &repository, remote_name)
338+
.unwrap_or_else(|e| panic!("{}", e));
299339

300340
println!(
301341
"Searching for {} identifier in {} repo",
302342
style(identifier).bold(),
303-
style(repository).bold()
343+
style(&repository).bold()
304344
);
305345
let stack =
306-
build_pr_stack_for_repo(identifier, repository, &credentials, get_excluded(m))
346+
build_pr_stack_for_repo(identifier, &repository, &credentials, get_excluded(m))
307347
.await?;
308348

309349
// Check for empty stack
@@ -356,34 +396,29 @@ async fn main() -> Result<(), Box<dyn Error>> {
356396
("autorebase", Some(m)) => {
357397
let identifier = m.value_of("identifier").unwrap();
358398

359-
// store the value of GHSTACK_TARGET_REPOSITORY
360-
let repository = env::var("GHSTACK_TARGET_REPOSITORY").unwrap_or_default();
361-
// replace it with the -r argument value if set
362-
let repository = m.value_of("repository").unwrap_or(&repository);
363-
// if repository is still unset, throw an error
364-
if repository.is_empty() {
365-
panic!(
366-
"You must pass a repository with the -r flag or set GHSTACK_TARGET_REPOSITORY"
367-
);
368-
}
399+
// defaults to "origin" if no remote is specified
400+
let remote_name = m.value_of("origin").unwrap_or("origin");
401+
402+
// resolve repository with fallback chain
403+
let repository = resolve_repository(m.value_of("repository"), &repository, remote_name)
404+
.unwrap_or_else(|e| panic!("{}", e));
369405

370406
println!(
371407
"Searching for {} identifier in {} repo",
372408
style(identifier).bold(),
373-
style(repository).bold()
409+
style(&repository).bold()
374410
);
375411
let stack =
376-
build_pr_stack_for_repo(identifier, repository, &credentials, get_excluded(m))
412+
build_pr_stack_for_repo(identifier, &repository, &credentials, get_excluded(m))
377413
.await?;
378414

379415
let project = m
380416
.value_of("project")
381417
.expect("The --project argument is required.");
382418
let project = Repository::open(project)?;
383419

384-
// defaults to "origin" if no remote is specified
385-
let remote = m.value_of("origin").unwrap_or("origin");
386-
let remote = project.find_remote(remote).unwrap();
420+
// use the same remote name for finding the remote to push to
421+
let remote = project.find_remote(remote_name).unwrap();
387422

388423
// if ci flag is set, set ci to true
389424
let ci = m.is_present("ci");
@@ -402,22 +437,19 @@ async fn main() -> Result<(), Box<dyn Error>> {
402437
("land", Some(m)) => {
403438
let identifier = m.value_of("identifier").unwrap();
404439

405-
// Get repository from args or environment
406-
let repository = m.value_of("repository").unwrap_or(&repository);
407-
if repository.is_empty() {
408-
panic!(
409-
"You must pass a repository with the -r flag or set GHSTACK_TARGET_REPOSITORY"
410-
);
411-
}
440+
// resolve repository with fallback chain
441+
let remote_name = m.value_of("origin").unwrap_or("origin");
442+
let repository = resolve_repository(m.value_of("repository"), &repository, remote_name)
443+
.unwrap_or_else(|e| panic!("{}", e));
412444

413445
println!(
414446
"Analyzing stack for {} in {}...\n",
415447
style(identifier).bold(),
416-
style(repository).bold()
448+
style(&repository).bold()
417449
);
418450

419451
let stack =
420-
build_pr_stack_for_repo(identifier, repository, &credentials, get_excluded(m))
452+
build_pr_stack_for_repo(identifier, &repository, &credentials, get_excluded(m))
421453
.await?;
422454

423455
if stack.is_empty() {
@@ -438,7 +470,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
438470
};
439471

440472
// Create the landing plan
441-
let plan = match land::create_land_plan(&stack, repository, &options) {
473+
let plan = match land::create_land_plan(&stack, &repository, &options) {
442474
Ok(plan) => plan,
443475
Err(e) => {
444476
match &e {

src/tree.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,45 @@ pub fn detect_repo() -> Option<Repository> {
6666
Repository::discover(".").ok()
6767
}
6868

69+
/// Try to detect repository (owner/repo) from git remote
70+
///
71+
/// # Arguments
72+
/// * `remote_name` - Name of the remote to use (typically "origin")
73+
pub fn detect_repo_from_remote(remote_name: &str) -> Option<String> {
74+
let repo = detect_repo()?;
75+
let remote = repo.find_remote(remote_name).ok()?;
76+
let url = remote.url()?;
77+
parse_github_remote_url(url)
78+
}
79+
80+
/// Parse a GitHub remote URL to extract owner/repo
81+
///
82+
/// Handles:
83+
/// - SSH: git@github.com:owner/repo.git
84+
/// - SSH (Enterprise): git@github.mycompany.com:owner/repo.git
85+
/// - HTTPS: https://github.com/owner/repo.git
86+
/// - HTTPS (Enterprise): https://github.mycompany.com/owner/repo.git
87+
/// - Without .git suffix
88+
fn parse_github_remote_url(url: &str) -> Option<String> {
89+
// SSH format: git@<host>:owner/repo.git
90+
if url.starts_with("git@") {
91+
let path = url.split(':').nth(1)?;
92+
let repo = path.trim_end_matches(".git");
93+
return Some(repo.to_string());
94+
}
95+
96+
// HTTPS format: https://<host>/owner/repo.git
97+
if url.starts_with("https://") || url.starts_with("http://") {
98+
let without_protocol = url.split("://").nth(1)?;
99+
// Skip the host part, get everything after first /
100+
let path = without_protocol.splitn(2, '/').nth(1)?;
101+
let repo = path.trim_end_matches(".git");
102+
return Some(repo.to_string());
103+
}
104+
105+
None
106+
}
107+
69108
/// Get current branch name from repo
70109
pub fn current_branch(repo: &Repository) -> Option<String> {
71110
repo.head().ok()?.shorthand().map(String::from)
@@ -1162,4 +1201,71 @@ mod tests {
11621201
let output = render(&entries, &config, true);
11631202
insta::assert_snapshot!(output);
11641203
}
1204+
1205+
// Tests for parse_github_remote_url
1206+
#[test]
1207+
fn test_parse_github_remote_url_ssh() {
1208+
assert_eq!(
1209+
parse_github_remote_url("git@github.com:owner/repo.git"),
1210+
Some("owner/repo".to_string())
1211+
);
1212+
}
1213+
1214+
#[test]
1215+
fn test_parse_github_remote_url_ssh_no_suffix() {
1216+
assert_eq!(
1217+
parse_github_remote_url("git@github.com:owner/repo"),
1218+
Some("owner/repo".to_string())
1219+
);
1220+
}
1221+
1222+
#[test]
1223+
fn test_parse_github_remote_url_https() {
1224+
assert_eq!(
1225+
parse_github_remote_url("https://github.com/owner/repo.git"),
1226+
Some("owner/repo".to_string())
1227+
);
1228+
}
1229+
1230+
#[test]
1231+
fn test_parse_github_remote_url_https_no_suffix() {
1232+
assert_eq!(
1233+
parse_github_remote_url("https://github.com/owner/repo"),
1234+
Some("owner/repo".to_string())
1235+
);
1236+
}
1237+
1238+
#[test]
1239+
fn test_parse_github_remote_url_http() {
1240+
assert_eq!(
1241+
parse_github_remote_url("http://github.com/owner/repo.git"),
1242+
Some("owner/repo".to_string())
1243+
);
1244+
}
1245+
1246+
#[test]
1247+
fn test_parse_github_remote_url_enterprise_ssh() {
1248+
assert_eq!(
1249+
parse_github_remote_url("git@github.mycompany.com:org/project.git"),
1250+
Some("org/project".to_string())
1251+
);
1252+
}
1253+
1254+
#[test]
1255+
fn test_parse_github_remote_url_enterprise_https() {
1256+
assert_eq!(
1257+
parse_github_remote_url("https://github.mycompany.com/org/project.git"),
1258+
Some("org/project".to_string())
1259+
);
1260+
}
1261+
1262+
#[test]
1263+
fn test_parse_github_remote_url_invalid() {
1264+
assert_eq!(parse_github_remote_url("not-a-url"), None);
1265+
}
1266+
1267+
#[test]
1268+
fn test_parse_github_remote_url_empty() {
1269+
assert_eq!(parse_github_remote_url(""), None);
1270+
}
11651271
}

0 commit comments

Comments
 (0)