use crate::github::pr_arg::PrArg;
use crate::github::remote::{git_remote_origin_url, github_remote_matches};
pub(crate) fn resolve_pr_workdir(parsed: &PrArg) -> anyhow::Result<Option<std::path::PathBuf>> {
let PrArg::Url { owner, repo, .. } = parsed else {
return Ok(None);
};
if let Some(origin) = git_remote_origin_url(None)?
&& github_remote_matches(&origin, owner, repo)
{
log::info!("using the current directory as a clone of {owner}/{repo}");
return Ok(None);
}
let ghq_candidates = ghq_candidate_clones(owner, repo);
if let Some(discovered) = select_matching_clone(
&ghq_candidates,
|path| git_remote_origin_url(Some(path)).ok().flatten(),
owner,
repo,
) {
log::info!(
"using ghq-managed clone of {owner}/{repo} at {}",
discovered.display()
);
return Ok(Some(discovered));
}
let dir = cache_repo_dir(
std::env::var("RINKAKU_CACHE_DIR").ok().as_deref(),
std::env::var("XDG_CACHE_HOME").ok().as_deref(),
std::env::var("HOME").ok().as_deref(),
owner,
repo,
)?;
if !dir.exists() {
std::fs::create_dir_all(dir.parent().unwrap_or(&dir)).map_err(|source| {
anyhow::anyhow!(
"failed to create cache directory for {}: {source}",
dir.display()
)
})?;
log::info!(
"cloning {owner}/{repo} into cache at {} (first run against this repository)",
dir.display()
);
clone_repo_into_cache(owner, repo, &dir)?;
} else {
log::info!("using cache clone of {owner}/{repo} at {}", dir.display());
}
Ok(Some(dir))
}
pub(crate) fn cache_repo_dir(
rinkaku_cache_dir: Option<&str>,
xdg_cache_home: Option<&str>,
home: Option<&str>,
owner: &str,
repo: &str,
) -> anyhow::Result<std::path::PathBuf> {
let root = if let Some(dir) = rinkaku_cache_dir {
std::path::PathBuf::from(dir)
} else if let Some(dir) = xdg_cache_home {
std::path::PathBuf::from(dir).join("rinkaku")
} else if let Some(dir) = home {
std::path::PathBuf::from(dir).join(".cache").join("rinkaku")
} else {
anyhow::bail!(
"cannot determine a cache directory for --pr: set $RINKAKU_CACHE_DIR, \
$XDG_CACHE_HOME, or $HOME"
);
};
Ok(root.join("repos").join("github.com").join(owner).join(repo))
}
pub(crate) fn clone_repo_into_cache(
owner: &str,
repo: &str,
dir: &std::path::Path,
) -> anyhow::Result<()> {
let slug = format!("{owner}/{repo}");
let output = std::process::Command::new("gh")
.args([
"repo",
"clone",
&slug,
&dir.to_string_lossy(),
"--",
"--filter=blob:none",
])
.output()?;
if !output.status.success() {
anyhow::bail!(
"gh repo clone {slug} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub(crate) fn parse_ghq_list_output(stdout: &str) -> Vec<std::path::PathBuf> {
stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(std::path::PathBuf::from)
.collect()
}
pub(crate) fn ghq_candidate_clones(owner: &str, repo: &str) -> Vec<std::path::PathBuf> {
let slug = format!("{owner}/{repo}");
let output = match std::process::Command::new("ghq")
.args(["list", "--full-path", "--exact", &slug])
.output()
{
Ok(output) => output,
Err(source) => {
log::debug!("ghq not runnable, falling back to cache for {slug}: {source}");
return Vec::new();
}
};
if !output.status.success() {
log::debug!(
"ghq list {slug} exited non-zero, falling back to cache: {}",
String::from_utf8_lossy(&output.stderr)
);
return Vec::new();
}
match String::from_utf8(output.stdout) {
Ok(stdout) => parse_ghq_list_output(&stdout),
Err(source) => {
log::debug!(
"ghq list {slug} produced non-UTF-8 output, falling back to cache: {source}"
);
Vec::new()
}
}
}
pub(crate) fn select_matching_clone(
candidates: &[std::path::PathBuf],
origin_of: impl Fn(&std::path::Path) -> Option<String>,
owner: &str,
repo: &str,
) -> Option<std::path::PathBuf> {
candidates
.iter()
.find(|candidate| {
origin_of(candidate).is_some_and(|origin| github_remote_matches(&origin, owner, repo))
})
.cloned()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::commands::resolve_repo_root;
use crate::test_util::init_repo_with_committed_file;
use pretty_assertions::assert_eq;
use rstest::rstest;
#[rstest]
#[case::should_prefer_rinkaku_cache_dir_when_set(
Some("/custom/cache"),
Some("/xdg/cache"),
Some("/home/user"),
"/custom/cache/repos/github.com/octocat/hello-world"
)]
#[case::should_fall_back_to_xdg_cache_home_when_rinkaku_cache_dir_unset(
None,
Some("/xdg/cache"),
Some("/home/user"),
"/xdg/cache/rinkaku/repos/github.com/octocat/hello-world"
)]
#[case::should_fall_back_to_home_when_neither_env_var_set(
None,
None,
Some("/home/user"),
"/home/user/.cache/rinkaku/repos/github.com/octocat/hello-world"
)]
fn should_build_cache_repo_dir(
#[case] rinkaku_cache_dir: Option<&str>,
#[case] xdg_cache_home: Option<&str>,
#[case] home: Option<&str>,
#[case] expected: &str,
) {
let actual = cache_repo_dir(
rinkaku_cache_dir,
xdg_cache_home,
home,
"octocat",
"hello-world",
)
.expect("expected a cache directory to be resolved");
assert_eq!(std::path::PathBuf::from(expected), actual);
}
#[test]
fn should_fail_to_build_cache_repo_dir_when_no_env_source_is_available() {
let actual = cache_repo_dir(None, None, None, "octocat", "hello-world");
assert!(actual.is_err());
}
#[rstest]
#[case::should_parse_single_line(
"/home/user/ghq/github.com/octocat/hello-world\n",
vec![std::path::PathBuf::from("/home/user/ghq/github.com/octocat/hello-world")]
)]
#[case::should_parse_multiple_lines(
"/home/user/ghq/github.com/octocat/hello-world\n/home/user/work/hello-world\n",
vec![
std::path::PathBuf::from("/home/user/ghq/github.com/octocat/hello-world"),
std::path::PathBuf::from("/home/user/work/hello-world"),
]
)]
#[case::should_skip_blank_lines_between_entries(
"/home/user/ghq/github.com/octocat/hello-world\n\n/home/user/work/hello-world\n",
vec![
std::path::PathBuf::from("/home/user/ghq/github.com/octocat/hello-world"),
std::path::PathBuf::from("/home/user/work/hello-world"),
]
)]
#[case::should_trim_surrounding_whitespace_per_line(
" /home/user/ghq/github.com/octocat/hello-world \n",
vec![std::path::PathBuf::from("/home/user/ghq/github.com/octocat/hello-world")]
)]
#[case::should_return_empty_vec_for_empty_string("", vec![])]
#[case::should_return_empty_vec_for_whitespace_only_string("\n\n \n", vec![])]
fn should_parse_ghq_list_output(
#[case] stdout: &str,
#[case] expected: Vec<std::path::PathBuf>,
) {
let actual = parse_ghq_list_output(stdout);
assert_eq!(expected, actual);
}
#[rstest]
#[case::should_return_first_candidate_when_it_matches(
vec!["/a", "/b"],
vec![("/a", "https://github.com/octocat/hello-world.git")],
Some(std::path::PathBuf::from("/a"))
)]
#[case::should_return_later_candidate_when_earlier_ones_mismatch(
vec!["/a", "/b", "/c"],
vec![
("/a", "https://github.com/someone-else/other-repo.git"),
("/b", "https://github.com/octocat/hello-world.git"),
],
Some(std::path::PathBuf::from("/b"))
)]
#[case::should_return_none_when_no_candidate_matches(
vec!["/a", "/b"],
vec![
("/a", "https://github.com/someone-else/other-repo.git"),
("/b", "https://gitlab.com/octocat/hello-world.git"),
],
None
)]
#[case::should_return_none_when_candidates_is_empty(vec![], vec![], None)]
#[case::should_return_none_when_origin_lookup_yields_nothing_for_any_candidate(
vec!["/a"],
vec![],
None
)]
fn should_select_matching_clone(
#[case] candidates: Vec<&str>,
#[case] origins: Vec<(&str, &str)>,
#[case] expected: Option<std::path::PathBuf>,
) {
let candidates: Vec<std::path::PathBuf> = candidates
.into_iter()
.map(std::path::PathBuf::from)
.collect();
let origin_of = |path: &std::path::Path| {
origins
.iter()
.find(|(candidate_path, _)| std::path::Path::new(candidate_path) == path)
.map(|(_, origin)| origin.to_string())
};
let actual = select_matching_clone(&candidates, origin_of, "octocat", "hello-world");
assert_eq!(expected, actual);
}
#[test]
fn should_resolve_pr_workdir_root_not_process_cwd_repo_when_both_are_git_repos() {
let process_repo = tempfile::TempDir::new().expect("create process repo tempdir");
init_repo_with_committed_file(process_repo.path(), "fn process_repo_marker() {}\n");
let pr_repo = tempfile::TempDir::new().expect("create pr repo tempdir");
init_repo_with_committed_file(pr_repo.path(), "fn pr_repo_marker() {}\n");
let actual = resolve_repo_root(Some(pr_repo.path()));
let expected = pr_repo
.path()
.canonicalize()
.expect("canonicalize expected");
let actual = actual.canonicalize().expect("canonicalize actual");
assert_eq!(expected, actual);
assert_ne!(
process_repo
.path()
.canonicalize()
.expect("canonicalize process_repo"),
actual,
"must not resolve the unrelated process-cwd repository"
);
}
}