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.
This commit is contained in:
dualfroz committed 2026-07-07 19:00:08 +02:00
1 parent 4b0a6e324f
commit 3eb922559c
3 files changed
+52 -13

No files matched your search

+12 -7
View File
@@ -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(); 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 Some(acc) = accounts.iter().find(|a| a.is_default).or(accounts.first()) {
if let Ok(provider) = crate::providers::make_provider(acc) { if let Ok(provider) = crate::providers::make_provider(acc) {
let repo_basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) let repo_basename = r.remote_repo_name.clone().unwrap_or_else(|| {
.unwrap_or_else(|| r.name.clone()); 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 { match provider.create_release(&repo_basename, tag, tag, notes, false).await {
Ok(url) => println!("{url}"), Ok(url) => println!("{url}"),
Err(e) => eprintln!("warning: tag pushed, but provider release failed: {e:#}"), 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); let r = find_repo(cfg, &repo);
super::guard_protected(cfg, guard.force, &[&r]); super::guard_protected(cfg, guard.force, &[&r]);
let repo_basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) let repo_basename = r.remote_repo_name.clone().unwrap_or_else(|| {
.unwrap_or_else(|| r.name.clone()); r.path.file_name().map(|b| b.to_string_lossy().to_string()).unwrap_or_else(|| r.name.clone())
});
if guard.dry_run { if guard.dry_run {
println!("DRY RUN: would invite {user} to {repo_basename} ({role})"); println!("DRY RUN: would invite {user} to {repo_basename} ({role})");
return Ok(()); return Ok(());
@@ -316,11 +318,14 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> {
let repos = super::scan_repos(cfg); let repos = super::scan_repos(cfg);
// Org-owned repos are reachable through the personal token — refresh // 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() let targets: Vec<(String, String, String)> = repos.iter()
.filter_map(|r| { .filter_map(|r| {
let basename = r.path.file_name().map(|b| b.to_string_lossy().to_string()) let basename = r.remote_repo_name.clone().unwrap_or_else(|| {
.unwrap_or_else(|| r.name.clone()); 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())) r.owner.as_ref().map(|o| (r.name.clone(), format!("{}/{}", o, basename), r.branch.clone()))
}) })
.collect(); .collect();
+7 -5
View File
@@ -718,14 +718,16 @@ pub fn spawn_api_refresh(state: &mut HubState) {
Ok(a) if !a.is_empty() => a, Ok(a) if !a.is_empty() => a,
_ => { state.status_msg = Some("No accounts configured — vulp auth add".into()); return; } _ => { 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 // 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, String)> = state.repos.iter() let repos: Vec<(String, Option<String>, String, String)> = state.repos.iter()
.map(|r| { .map(|r| {
let basename = r.path.file_name() let basename = r.remote_repo_name.clone().unwrap_or_else(|| {
.map(|b| b.to_string_lossy().to_string()) r.path.file_name().map(|b| b.to_string_lossy().to_string()).unwrap_or_else(|| r.name.clone())
.unwrap_or_else(|| r.name.clone()); });
(r.name.clone(), r.owner.clone(), basename, r.branch.clone()) (r.name.clone(), r.owner.clone(), basename, r.branch.clone())
}) })
.collect(); .collect();
+33 -1
View File
@@ -29,6 +29,10 @@ pub struct RepoSummary {
pub last_tag: Option<String>, pub last_tag: Option<String>,
pub visibility: Option<String>, pub visibility: Option<String>,
pub owner: Option<String>, // parsed from git remote URL pub owner: Option<String>, // 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<String>,
pub has_license: bool, pub has_license: bool,
pub lang: Option<String>, pub lang: Option<String>,
pub bookmarked: bool, pub bookmarked: bool,
@@ -94,12 +98,39 @@ fn parse_owner_from_url(url: &str) -> Option<String> {
None 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<String> {
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. /// Parses the owner from `repo`'s `origin` remote, if it has one.
fn extract_owner(repo: &Repository) -> Option<String> { fn extract_owner(repo: &Repository) -> Option<String> {
let remote = repo.find_remote("origin").ok()?; let remote = repo.find_remote("origin").ok()?;
parse_owner_from_url(remote.url()?) 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<String> {
let remote = repo.find_remote("origin").ok()?;
parse_repo_name_from_url(remote.url()?)
}
// ── license + language detection ────────────────────────────────────────────── // ── license + language detection ──────────────────────────────────────────────
/// Whether `path` has a recognizable license file at its root. /// Whether `path` has a recognizable license file at its root.
@@ -372,6 +403,7 @@ pub fn repo_summary(path: &Path) -> Result<RepoSummary> {
let tag_ms = t_tag.elapsed().as_millis(); let tag_ms = t_tag.elapsed().as_millis();
let owner = extract_owner(&repo); let owner = extract_owner(&repo);
let remote_repo_name = extract_remote_repo_name(&repo);
let has_license = detect_license(path); let has_license = detect_license(path);
let t_lang = std::time::Instant::now(); let t_lang = std::time::Instant::now();
@@ -399,7 +431,7 @@ pub fn repo_summary(path: &Path) -> Result<RepoSummary> {
Ok(RepoSummary { Ok(RepoSummary {
name, path: path.to_path_buf(), branch, ahead, behind, is_dirty, name, path: path.to_path_buf(), branch, ahead, behind, is_dirty,
last_commit_msg: msg, last_commit_age: age, last_commit_ts: ts, 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, stars: None, open_issues: None, open_prs: None,
stash_count, stash_count,
ci_status: None, dependabot_open: None, deploy_status: None, ci_status: None, dependabot_open: None, deploy_status: None,