From 3eb922559c334afae87daf91e1c9bd0bb57ea071 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 7 Jul 2026 19:00:08 +0200 Subject: [PATCH] fix: provider API calls used the local folder name, not the actual repo name Every place building an owner/repo slug for the provider API (refresh, release, add_collaborator) used path.file_name() as the repo segment - the local checkout's directory name. That's usually the same as the GitHub repo name, but not always: vulp's own folder is 'vulp', its GitHub repo is 'fxg', so every refresh silently 404'd fetching its own metadata/PRs/issues/CI (the cache carried an empty meta block with fetched_at: 0, never actually set). RepoSummary now also carries remote_repo_name, parsed from the git remote URL alongside owner (same source, same parsing approach) - the segment the provider actually knows the repo by. All four slug-building call sites now prefer it, falling back to the folder name only when there's no remote to parse it from. Verified: 'vulp refresh' now correctly hits dualfroz/fxg and the cache shows real stars/issues/PRs for vulp instead of the empty stub. --- src/cli_cmds/remote.rs | 19 ++++++++++++------- src/hub/modals_spawn.rs | 12 +++++++----- src/status/mod.rs | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/cli_cmds/remote.rs b/src/cli_cmds/remote.rs index 29c4b71..d72eb74 100644 --- a/src/cli_cmds/remote.rs +++ b/src/cli_cmds/remote.rs @@ -95,8 +95,9 @@ pub async fn release(cfg: &AppConfig, guard: super::Guard, repo: &str, tag: &str let accounts = crate::auth::load_accounts(cfg).unwrap_or_default(); if let Some(acc) = accounts.iter().find(|a| a.is_default).or(accounts.first()) { if let Ok(provider) = crate::providers::make_provider(acc) { - let repo_basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) - .unwrap_or_else(|| r.name.clone()); + let repo_basename = r.remote_repo_name.clone().unwrap_or_else(|| { + r.path.file_name().map(|b| b.to_string_lossy().to_string()).unwrap_or_else(|| r.name.clone()) + }); match provider.create_release(&repo_basename, tag, tag, notes, false).await { Ok(url) => println!("{url}"), Err(e) => eprintln!("warning: tag pushed, but provider release failed: {e:#}"), @@ -168,8 +169,9 @@ pub async fn repo_cmd(cfg: &AppConfig, json: bool, guard: super::Guard, cmd: Rep } let r = find_repo(cfg, &repo); super::guard_protected(cfg, guard.force, &[&r]); - let repo_basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) - .unwrap_or_else(|| r.name.clone()); + let repo_basename = r.remote_repo_name.clone().unwrap_or_else(|| { + r.path.file_name().map(|b| b.to_string_lossy().to_string()).unwrap_or_else(|| r.name.clone()) + }); if guard.dry_run { println!("DRY RUN: would invite {user} to {repo_basename} ({role})"); return Ok(()); @@ -316,11 +318,14 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> { let repos = super::scan_repos(cfg); // Org-owned repos are reachable through the personal token — refresh - // everything with an owner, addressed by full "owner/basename" slug. + // everything with an owner, addressed by full "owner/repo" slug. The + // repo name comes from the remote URL, not the local folder name — they + // can differ (vulp's own folder is "vulp", its GitHub repo is "fxg"). let targets: Vec<(String, String, String)> = repos.iter() .filter_map(|r| { - let basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) - .unwrap_or_else(|| r.name.clone()); + let basename = r.remote_repo_name.clone().unwrap_or_else(|| { + r.path.file_name().map(|b| b.to_string_lossy().to_string()).unwrap_or_else(|| r.name.clone()) + }); r.owner.as_ref().map(|o| (r.name.clone(), format!("{}/{}", o, basename), r.branch.clone())) }) .collect(); diff --git a/src/hub/modals_spawn.rs b/src/hub/modals_spawn.rs index b76e335..5ca0a1f 100644 --- a/src/hub/modals_spawn.rs +++ b/src/hub/modals_spawn.rs @@ -718,14 +718,16 @@ pub fn spawn_api_refresh(state: &mut HubState) { Ok(a) if !a.is_empty() => a, _ => { state.status_msg = Some("No accounts configured — vulp auth add".into()); return; } }; - // (display name = cache key, owner/basename slug for the API, branch for + // (display name = cache key, owner/repo slug for the API, branch for // the CI-run lookup). Org-owned repos are reachable through the personal - // token, so no owner filtering. + // token, so no owner filtering. The repo name comes from the remote URL, + // not the local folder name — they can differ (vulp's own folder is + // "vulp", its GitHub repo is "fxg"). let repos: Vec<(String, Option, String, String)> = state.repos.iter() .map(|r| { - let basename = r.path.file_name() - .map(|b| b.to_string_lossy().to_string()) - .unwrap_or_else(|| r.name.clone()); + let basename = r.remote_repo_name.clone().unwrap_or_else(|| { + r.path.file_name().map(|b| b.to_string_lossy().to_string()).unwrap_or_else(|| r.name.clone()) + }); (r.name.clone(), r.owner.clone(), basename, r.branch.clone()) }) .collect(); diff --git a/src/status/mod.rs b/src/status/mod.rs index 9e7eb48..2199488 100644 --- a/src/status/mod.rs +++ b/src/status/mod.rs @@ -29,6 +29,10 @@ pub struct RepoSummary { pub last_tag: Option, pub visibility: Option, pub owner: Option, // parsed from git remote URL + /// The provider-side repo name parsed from the git remote URL — not + /// necessarily the local folder name (see `parse_repo_name_from_url`). + /// Provider-API slugs must use this, never `path.file_name()`. + pub remote_repo_name: Option, pub has_license: bool, pub lang: Option, pub bookmarked: bool, @@ -94,12 +98,39 @@ fn parse_owner_from_url(url: &str) -> Option { None } +/// Extracts the `repo` segment from an HTTPS or SSH git remote URL — the +/// name the provider actually knows the repo by, which is not necessarily +/// the local folder's name (a repo can be cloned into, or renamed to, any +/// directory name locally; `vulp`'s own local folder is "vulp" but its +/// GitHub repo is "fxg"). Callers building a provider-API slug must prefer +/// this over `path.file_name()`. +fn parse_repo_name_from_url(url: &str) -> Option { + if url.starts_with("http") { + let trimmed = url.trim_end_matches('/').trim_end_matches(".git"); + return trimmed.rsplit('/').next().map(|s| s.to_string()); + } + // git@github.com:owner/repo.git + if let Some(colon) = url.rfind(':') { + let after = url[colon + 1..].trim_start_matches('/').trim_end_matches(".git"); + return after.rsplit('/').next().map(|s| s.to_string()); + } + None +} + /// Parses the owner from `repo`'s `origin` remote, if it has one. fn extract_owner(repo: &Repository) -> Option { let remote = repo.find_remote("origin").ok()?; parse_owner_from_url(remote.url()?) } +/// Parses the provider-side repo name from `repo`'s `origin` remote, if it +/// has one — see `parse_repo_name_from_url` for why this can differ from +/// the local folder name. +fn extract_remote_repo_name(repo: &Repository) -> Option { + let remote = repo.find_remote("origin").ok()?; + parse_repo_name_from_url(remote.url()?) +} + // ── license + language detection ────────────────────────────────────────────── /// Whether `path` has a recognizable license file at its root. @@ -372,6 +403,7 @@ pub fn repo_summary(path: &Path) -> Result { let tag_ms = t_tag.elapsed().as_millis(); let owner = extract_owner(&repo); + let remote_repo_name = extract_remote_repo_name(&repo); let has_license = detect_license(path); let t_lang = std::time::Instant::now(); @@ -399,7 +431,7 @@ pub fn repo_summary(path: &Path) -> Result { Ok(RepoSummary { name, path: path.to_path_buf(), branch, ahead, behind, is_dirty, last_commit_msg: msg, last_commit_age: age, last_commit_ts: ts, - last_tag, visibility: None, owner, has_license, lang, bookmarked: false, + last_tag, visibility: None, owner, remote_repo_name, has_license, lang, bookmarked: false, stars: None, open_issues: None, open_prs: None, stash_count, ci_status: None, dependabot_open: None, deploy_status: None,