use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::Context;
use clap::Parser;
use serde_derive::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ConfigurationValues {
pub worktree_dir: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RepoConfig {
pub repo: String,
pub local_path: PathBuf,
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct GitCookbookConfig {
repo_globs: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum GithubItemKind {
PullRequest { head_ref_name: String },
Issue,
}
#[derive(Debug, Clone)]
pub struct GithubItem {
pub number: u64,
pub title: String,
pub repo: String,
pub kind: GithubItemKind,
pub updated_at: String,
}
#[derive(Parser)]
enum EnwiroCookbookGithub {
ListRecipes(ListRecipesArgs),
Cook(CookArgs),
Metadata,
}
#[derive(clap::Args)]
pub struct ListRecipesArgs {}
#[derive(clap::Args)]
pub struct CookArgs {
recipe_name: String,
}
fn short_path_hash(path: &Path) -> String {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(path.to_string_lossy().as_bytes());
format!("{:x}", hash)[..8].to_string()
}
fn default_worktree_dir() -> anyhow::Result<PathBuf> {
let base = dirs::data_dir().context("Could not determine data directory (is $HOME set?)")?;
Ok(base.join("enwiro").join("worktrees").join("pr"))
}
fn worktree_base_dir(config: &ConfigurationValues) -> anyhow::Result<PathBuf> {
match &config.worktree_dir {
Some(dir) => Ok(PathBuf::from(dir)),
None => default_worktree_dir(),
}
}
fn parse_github_remote(url: &str) -> Option<String> {
let url = url.trim();
if let Some(rest) = url.strip_prefix("git@github.com:") {
let repo = rest.strip_suffix(".git").unwrap_or(rest);
return if repo.contains('/') {
Some(repo.to_string())
} else {
None
};
}
let path = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))
.or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
let repo = path.strip_suffix(".git").unwrap_or(path);
if repo.contains('/') {
Some(repo.to_string())
} else {
None
}
}
fn discover_github_repos_from_config(
git_config: &GitCookbookConfig,
) -> anyhow::Result<Vec<RepoConfig>> {
let mut results = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for glob_pattern in &git_config.repo_globs {
let paths = glob::glob(glob_pattern)
.with_context(|| format!("Could not parse glob pattern: {}", glob_pattern))?;
for path in paths.flatten() {
let repo = match git2::Repository::open(&path) {
Ok(r) => r,
Err(_) => continue,
};
let origin = match repo.find_remote("origin") {
Ok(r) => r,
Err(_) => continue,
};
let url = match origin.url() {
Some(u) => u.to_string(),
None => continue,
};
if let Some(github_repo) = parse_github_remote(&url)
&& seen.insert(github_repo.clone())
{
let canonical_path = path.canonicalize().unwrap_or(path);
tracing::debug!(repo = %github_repo, path = %canonical_path.display(), "Discovered GitHub repo");
results.push(RepoConfig {
repo: github_repo,
local_path: canonical_path,
});
}
}
}
Ok(results)
}
fn discover_github_repos() -> anyhow::Result<Vec<RepoConfig>> {
let git_config: GitCookbookConfig = confy::load("enwiro", "cookbook-git")
.context("Could not load git cookbook configuration")?;
discover_github_repos_from_config(&git_config)
}
fn parse_recipe_name(name: &str) -> anyhow::Result<(&str, u64)> {
let (repo, number_str) = name
.rsplit_once('#')
.context("Recipe name must contain '#' (expected format: repo#123)")?;
let number = number_str
.parse::<u64>()
.with_context(|| format!("Invalid issue/PR number: {}", number_str))?;
Ok((repo, number))
}
fn build_search_query(repos: &[String], type_filter: &str) -> String {
let repo_filters: Vec<String> = repos.iter().map(|r| format!("repo:{}", r)).collect();
let date_qualifier = if type_filter.contains("is:issue") {
String::new()
} else {
let cutoff = chrono::Utc::now() - chrono::Duration::days(30);
format!(" updated:>{}", cutoff.format("%Y-%m-%d"))
};
format!(
"{} {}{} sort:updated-desc",
type_filter,
repo_filters.join(" "),
date_qualifier,
)
}
#[derive(Deserialize)]
struct GraphQlResponse {
data: GraphQlData,
}
#[derive(Deserialize)]
struct GraphQlData {
search: GraphQlSearch,
}
#[derive(Deserialize)]
struct GraphQlSearch {
nodes: Vec<GraphQlNode>,
}
#[derive(Deserialize)]
struct GraphQlNode {
number: u64,
title: String,
#[serde(rename = "headRefName", default)]
head_ref_name: Option<String>,
#[serde(rename = "updatedAt", default)]
updated_at: Option<String>,
repository: GraphQlRepo,
}
#[derive(Deserialize)]
struct GraphQlRepo {
#[serde(rename = "nameWithOwner")]
name_with_owner: String,
}
fn extract_short_repo_name(name_with_owner: String) -> String {
name_with_owner
.rsplit_once('/')
.map(|(_, name)| name.to_string())
.unwrap_or(name_with_owner)
}
fn parse_search_response(json: &str) -> anyhow::Result<Vec<GithubItem>> {
let response: GraphQlResponse =
serde_json::from_str(json).context("Could not parse GraphQL response")?;
Ok(response
.data
.search
.nodes
.into_iter()
.map(|node| {
let repo = extract_short_repo_name(node.repository.name_with_owner);
let kind = match node.head_ref_name {
Some(head_ref_name) => GithubItemKind::PullRequest { head_ref_name },
None => GithubItemKind::Issue,
};
GithubItem {
number: node.number,
title: node.title,
repo,
kind,
updated_at: node.updated_at.unwrap_or_default(),
}
})
.collect())
}
const SEARCH_QUERY: &str = r#"query($searchQuery: String!) {
search(query: $searchQuery, type: ISSUE, first: 100) {
nodes {
... on PullRequest {
number
title
headRefName
updatedAt
repository { nameWithOwner }
}
... on Issue {
number
title
updatedAt
repository { nameWithOwner }
}
}
}
}"#;
fn search_github(repos: &[String], type_filter: &str) -> anyhow::Result<Vec<GithubItem>> {
if repos.is_empty() {
return Ok(Vec::new());
}
let search_query = build_search_query(repos, type_filter);
let output = Command::new("gh")
.args([
"api",
"graphql",
"-F",
&format!("searchQuery={}", search_query),
"-f",
&format!("query={}", SEARCH_QUERY),
])
.output()
.context(
"Failed to run gh CLI. Is it installed and authenticated? \
(https://cli.github.com/, then run: gh auth login)",
)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"gh api graphql failed: {}. Is gh authenticated? (try: gh auth login)",
stderr
);
}
let stdout = String::from_utf8(output.stdout).context("gh produced invalid UTF-8")?;
let items = parse_search_response(&stdout)?;
if items.len() >= 100 {
eprintln!(
"Warning: GitHub search returned 100 results (the maximum). Some results may be missing."
);
}
Ok(items)
}
fn sort_items_by_date(items: &mut [GithubItem]) {
items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
}
fn compute_sort_order(index: usize, total: usize) -> u32 {
if total <= 1 {
0
} else {
((index * 100) / (total - 1)) as u32
}
}
fn list_recipes() -> anyhow::Result<()> {
let repos = discover_github_repos()?;
let repo_names: Vec<String> = repos.iter().map(|r| r.repo.clone()).collect();
let prs = search_github(&repo_names, "is:pr is:open")?;
let issues = search_github(&repo_names, "is:issue is:open assignee:@me")?;
let mut items: Vec<GithubItem> = prs.into_iter().chain(issues).collect();
sort_items_by_date(&mut items);
let total = items.len();
for (index, item) in items.iter().enumerate() {
let safe_title = item.title.replace(['\n', '\0', '\x1f'], " ");
let prefix = match &item.kind {
GithubItemKind::PullRequest { .. } => "[PR]",
GithubItemKind::Issue => "[issue]",
};
let sort_order = compute_sort_order(index, total);
let recipe = serde_json::json!({
"name": format!("{}#{}", item.repo, item.number),
"description": format!("{} {}", prefix, safe_title),
"sort_order": sort_order,
});
println!("{}", serde_json::to_string(&recipe).unwrap());
}
Ok(())
}
fn resolve_repo_config(repo_str: &str) -> anyhow::Result<RepoConfig> {
let repos = discover_github_repos()?;
let matching: Vec<_> = repos
.into_iter()
.filter(|r| {
r.repo
.rsplit_once('/')
.map(|(_, name)| name)
.unwrap_or(&r.repo)
== repo_str
})
.collect();
anyhow::ensure!(
matching.len() <= 1,
"Ambiguous repo name '{}': matches {} configured repos. Use a more specific name.",
repo_str,
matching.len()
);
let repo_config = matching
.into_iter()
.next()
.with_context(|| format!("No configured repo matching '{}'", repo_str))?;
anyhow::ensure!(
repo_config.local_path.exists(),
"Local clone not found at {}. Please clone the repo first.",
repo_config.local_path.display()
);
Ok(repo_config)
}
fn print_worktree_path(wt_path: &Path) -> anyhow::Result<()> {
println!(
"{}",
wt_path
.to_str()
.context("Could not convert worktree path to string")?
);
Ok(())
}
fn worktree_path(
config: &ConfigurationValues,
repo_config: &RepoConfig,
repo_str: &str,
prefix: &str,
number: u64,
) -> anyhow::Result<PathBuf> {
let wt_base = worktree_base_dir(config)?;
let path_hash = short_path_hash(&repo_config.local_path);
Ok(wt_base
.join(format!("{}-{}", repo_str, path_hash))
.join(format!("{}-{}", prefix, number)))
}
fn cook_pr(
config: &ConfigurationValues,
repo_config: &RepoConfig,
repo_str: &str,
number: u64,
) -> anyhow::Result<()> {
let wt_path = worktree_path(config, repo_config, repo_str, "pr", number)?;
std::fs::create_dir_all(wt_path.parent().unwrap())
.context("Could not create worktree directory")?;
let ref_name = format!("pr-{}", number);
let repo = git2::Repository::open(&repo_config.local_path)
.context("Could not open repository for worktree creation")?;
let branch = repo
.find_branch(&ref_name, git2::BranchType::Local)
.with_context(|| format!("Could not find branch {}", ref_name))?;
let reference = branch.into_reference();
let wt_name = format!("enwiro-pr-{}", number);
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree(&wt_name, &wt_path, Some(&opts))
.with_context(|| format!("Could not create worktree for PR #{}", number))?;
tracing::debug!(path = %wt_path.display(), pr = number, "Created worktree for PR");
print_worktree_path(&wt_path)
}
fn get_default_branch(repo: &git2::Repository) -> anyhow::Result<String> {
if let Ok(reference) = repo.find_reference("refs/remotes/origin/HEAD")
&& let Ok(resolved) = reference.resolve()
&& let Some(name) = resolved.shorthand()
{
return Ok(name.strip_prefix("origin/").unwrap_or(name).to_string());
}
tracing::warn!("origin/HEAD is not set, probing for default branch");
for candidate in ["main", "master"] {
if repo
.find_reference(&format!("refs/remotes/origin/{}", candidate))
.is_ok()
{
tracing::debug!(branch = candidate, "Using fallback default branch");
return Ok(candidate.to_string());
}
}
anyhow::bail!(
"Could not determine default branch: origin/HEAD is not set and \
neither origin/main nor origin/master exist. \
Try running: git remote set-head origin --auto"
)
}
fn cook_issue(
config: &ConfigurationValues,
repo_config: &RepoConfig,
repo_str: &str,
number: u64,
) -> anyhow::Result<()> {
let wt_path = worktree_path(config, repo_config, repo_str, "issue", number)?;
std::fs::create_dir_all(wt_path.parent().unwrap())
.context("Could not create worktree directory")?;
let local_path_str = repo_config
.local_path
.to_str()
.context("Could not convert local path to string")?;
let fetch_status = Command::new("git")
.args(["-C", local_path_str, "fetch", "origin"])
.status()
.context("Failed to run git fetch")?;
if !fetch_status.success() {
anyhow::bail!("Failed to fetch from {}", repo_config.repo);
}
let repo = git2::Repository::open(&repo_config.local_path)
.context("Could not open repository for worktree creation")?;
let default_branch = get_default_branch(&repo)?;
let branch_name = format!("issue-{}", number);
let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) {
Ok(existing) => {
tracing::debug!(branch = %branch_name, "Reusing existing issue branch");
existing
}
Err(_) => {
let origin_ref = format!("origin/{}", default_branch);
let origin_commit = repo
.find_reference(&format!("refs/remotes/{}", origin_ref))
.with_context(|| format!("Could not find ref {}", origin_ref))?
.peel_to_commit()
.with_context(|| format!("Could not resolve {} to a commit", origin_ref))?;
repo.branch(&branch_name, &origin_commit, false)
.with_context(|| format!("Could not create branch {}", branch_name))?
}
};
let reference = branch.into_reference();
let wt_name = format!("enwiro-issue-{}", number);
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree(&wt_name, &wt_path, Some(&opts))
.with_context(|| format!("Could not create worktree for issue #{}", number))?;
tracing::debug!(path = %wt_path.display(), issue = number, "Created worktree for issue");
print_worktree_path(&wt_path)
}
fn cook(config: &ConfigurationValues, args: CookArgs) -> anyhow::Result<()> {
let (repo_str, number) = parse_recipe_name(&args.recipe_name)?;
let repo_config = resolve_repo_config(repo_str)?;
let pr_wt_path = worktree_path(config, &repo_config, repo_str, "pr", number)?;
let issue_wt_path = worktree_path(config, &repo_config, repo_str, "issue", number)?;
if pr_wt_path.exists() {
return print_worktree_path(&pr_wt_path);
}
if issue_wt_path.exists() {
return print_worktree_path(&issue_wt_path);
}
let wt_base = worktree_base_dir(config)?;
let path_hash = short_path_hash(&repo_config.local_path);
let old_repo_name = repo_config.repo.replace('/', "-");
let old_pr_wt_path = wt_base
.join(format!("{}-{}", old_repo_name, path_hash))
.join(format!("pr-{}", number));
if old_pr_wt_path.exists() {
return print_worktree_path(&old_pr_wt_path);
}
let local_path_str = repo_config
.local_path
.to_str()
.context("Could not convert local path to string")?;
let fetch_refspec = format!("pull/{}/head:pr-{}", number, number);
let fetch_output = Command::new("git")
.args(["-C", local_path_str, "fetch", "origin", &fetch_refspec])
.output()
.context("Failed to run git fetch")?;
if fetch_output.status.success() {
return cook_pr(config, &repo_config, repo_str, number);
}
let stderr = String::from_utf8_lossy(&fetch_output.stderr);
if stderr.contains("not found") || stderr.contains("couldn't find remote ref") {
cook_issue(config, &repo_config, repo_str, number)
} else {
anyhow::bail!(
"Failed to fetch #{} from {}: {}",
number,
repo_config.repo,
stderr.trim()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_recipe_name_valid() {
let (repo, number) = parse_recipe_name("enwiro#42").unwrap();
assert_eq!(repo, "enwiro");
assert_eq!(number, 42);
}
#[test]
fn test_parse_recipe_name_large_number() {
let (repo, number) = parse_recipe_name("next.js#12345").unwrap();
assert_eq!(repo, "next.js");
assert_eq!(number, 12345);
}
#[test]
fn test_parse_recipe_name_no_hash() {
let result = parse_recipe_name("enwiro");
assert!(result.is_err());
}
#[test]
fn test_parse_recipe_name_invalid_number() {
let result = parse_recipe_name("enwiro#abc");
assert!(result.is_err());
}
#[test]
fn test_build_search_query_pr() {
let repos = vec!["kantord/enwiro".to_string()];
let query = build_search_query(&repos, "is:pr is:open");
assert!(query.contains("repo:kantord/enwiro"));
assert!(query.contains("is:pr"));
assert!(query.contains("is:open"));
assert!(query.contains("sort:updated-desc"));
assert!(
query.contains("updated:>"),
"Should contain date filter, got: {}",
query
);
}
#[test]
fn test_build_search_query_issue() {
let repos = vec!["kantord/enwiro".to_string()];
let query = build_search_query(&repos, "is:issue is:open assignee:@me");
assert!(query.contains("repo:kantord/enwiro"));
assert!(query.contains("is:issue"));
assert!(query.contains("is:open"));
assert!(query.contains("assignee:@me"));
assert!(query.contains("sort:updated-desc"));
}
#[test]
fn test_build_search_query_issue_omits_date_filter() {
let repos = vec!["kantord/enwiro".to_string()];
let query = build_search_query(&repos, "is:issue is:open assignee:@me");
assert!(
!query.contains("updated:>"),
"Issue query must NOT include a date filter, but got: {}",
query
);
}
#[test]
fn test_build_search_query_pr_retains_date_filter() {
let repos = vec!["kantord/enwiro".to_string()];
let query = build_search_query(&repos, "is:pr is:open");
assert!(
query.contains("updated:>"),
"PR query MUST include a date filter, but got: {}",
query
);
}
#[test]
fn test_build_search_query_multiple_repos() {
let repos = vec![
"kantord/enwiro".to_string(),
"expressjs/express".to_string(),
];
let query = build_search_query(&repos, "is:pr is:open");
assert!(query.contains("repo:kantord/enwiro"));
assert!(query.contains("repo:expressjs/express"));
}
#[test]
fn test_parse_search_response_prs() {
let json = r#"{
"data": {
"search": {
"nodes": [
{
"number": 42,
"title": "Fix the thing",
"headRefName": "fix-thing",
"updatedAt": "2026-02-14T13:10:29Z",
"repository": { "nameWithOwner": "kantord/enwiro" }
},
{
"number": 99,
"title": "Add feature",
"headRefName": "feature/add-stuff",
"updatedAt": "2026-02-13T10:00:00Z",
"repository": { "nameWithOwner": "expressjs/express" }
}
]
}
}
}"#;
let items = parse_search_response(json).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].number, 42);
assert_eq!(items[0].title, "Fix the thing");
assert!(matches!(
&items[0].kind,
GithubItemKind::PullRequest { head_ref_name } if head_ref_name == "fix-thing"
));
assert_eq!(items[0].repo, "enwiro");
assert_eq!(items[1].number, 99);
assert_eq!(items[1].repo, "express");
}
#[test]
fn test_parse_search_response_issues() {
let json = r#"{
"data": {
"search": {
"nodes": [
{
"number": 225,
"title": "Discover GitHub Issues",
"updatedAt": "2026-02-14T13:10:29Z",
"repository": { "nameWithOwner": "kantord/enwiro" }
},
{
"number": 100,
"title": "Fix login bug",
"updatedAt": "2026-02-13T10:00:00Z",
"repository": { "nameWithOwner": "expressjs/express" }
}
]
}
}
}"#;
let items = parse_search_response(json).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].number, 225);
assert_eq!(items[0].title, "Discover GitHub Issues");
assert!(matches!(&items[0].kind, GithubItemKind::Issue));
assert_eq!(items[0].repo, "enwiro");
assert_eq!(items[1].number, 100);
assert!(matches!(&items[1].kind, GithubItemKind::Issue));
}
#[test]
fn test_parse_search_response_empty_nodes() {
let json = r#"{"data": {"search": {"nodes": []}}}"#;
let items = parse_search_response(json).unwrap();
assert!(items.is_empty());
}
#[test]
fn test_parse_github_remote_ssh() {
assert_eq!(
parse_github_remote("git@github.com:kantord/enwiro.git"),
Some("kantord/enwiro".to_string())
);
}
#[test]
fn test_parse_github_remote_https_with_git_suffix() {
assert_eq!(
parse_github_remote("https://github.com/kantord/enwiro.git"),
Some("kantord/enwiro".to_string())
);
}
#[test]
fn test_parse_github_remote_https_without_git_suffix() {
assert_eq!(
parse_github_remote("https://github.com/kantord/enwiro"),
Some("kantord/enwiro".to_string())
);
}
#[test]
fn test_parse_github_remote_ssh_protocol() {
assert_eq!(
parse_github_remote("ssh://git@github.com/kantord/enwiro.git"),
Some("kantord/enwiro".to_string())
);
}
#[test]
fn test_parse_github_remote_gitlab_returns_none() {
assert_eq!(
parse_github_remote("git@gitlab.com:kantord/project.git"),
None
);
}
#[test]
fn test_parse_github_remote_empty_string() {
assert_eq!(parse_github_remote(""), None);
}
#[test]
fn test_discover_finds_github_repo() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("enwiro");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
repo.remote("origin", "git@github.com:kantord/enwiro.git")
.unwrap();
let git_config = GitCookbookConfig {
repo_globs: vec![tmp.path().join("*").to_str().unwrap().to_string()],
};
let repos = discover_github_repos_from_config(&git_config).unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].repo, "kantord/enwiro");
assert_eq!(repos[0].local_path, repo_path.canonicalize().unwrap());
}
#[test]
fn test_discover_skips_non_github_repo() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("project");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
repo.remote("origin", "git@gitlab.com:kantord/project.git")
.unwrap();
let git_config = GitCookbookConfig {
repo_globs: vec![tmp.path().join("*").to_str().unwrap().to_string()],
};
let repos = discover_github_repos_from_config(&git_config).unwrap();
assert_eq!(repos.len(), 0);
}
#[test]
fn test_discover_skips_repo_without_origin() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("project");
std::fs::create_dir(&repo_path).unwrap();
git2::Repository::init(&repo_path).unwrap();
let git_config = GitCookbookConfig {
repo_globs: vec![tmp.path().join("*").to_str().unwrap().to_string()],
};
let repos = discover_github_repos_from_config(&git_config).unwrap();
assert_eq!(repos.len(), 0);
}
#[test]
fn test_discover_skips_non_repo_directory() {
let tmp = tempfile::TempDir::new().unwrap();
let not_a_repo = tmp.path().join("just-a-folder");
std::fs::create_dir(¬_a_repo).unwrap();
let git_config = GitCookbookConfig {
repo_globs: vec![tmp.path().join("*").to_str().unwrap().to_string()],
};
let repos = discover_github_repos_from_config(&git_config).unwrap();
assert_eq!(repos.len(), 0);
}
#[test]
fn test_discover_deduplicates_repos() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("enwiro");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
repo.remote("origin", "git@github.com:kantord/enwiro.git")
.unwrap();
let git_config = GitCookbookConfig {
repo_globs: vec![
tmp.path().join("*").to_str().unwrap().to_string(),
repo_path.to_str().unwrap().to_string(),
],
};
let repos = discover_github_repos_from_config(&git_config).unwrap();
assert_eq!(repos.len(), 1);
}
#[test]
fn test_default_worktree_dir_is_absolute() {
let dir = default_worktree_dir().unwrap();
assert!(
dir.is_absolute(),
"default_worktree_dir should return an absolute path, got: {:?}",
dir
);
}
#[test]
fn test_worktree_base_dir_uses_config() {
let config = ConfigurationValues {
worktree_dir: Some("/tmp/my-pr-worktrees".to_string()),
..Default::default()
};
let dir = worktree_base_dir(&config).unwrap();
assert_eq!(dir, PathBuf::from("/tmp/my-pr-worktrees"));
}
#[test]
fn test_cook_creates_worktree_for_pr() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("my-project");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("pr-42", &head, false).unwrap();
let wt_dir = tmp.path().join("worktrees");
let repo_config = RepoConfig {
repo: "kantord/enwiro".to_string(),
local_path: repo_path.clone(),
};
let path_hash = short_path_hash(&repo_config.local_path);
let wt_path = wt_dir
.join(format!("kantord-enwiro-{}", path_hash))
.join("pr-42");
std::fs::create_dir_all(wt_path.parent().unwrap()).unwrap();
let branch = repo.find_branch("pr-42", git2::BranchType::Local).unwrap();
let reference = branch.into_reference();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree("enwiro-pr-42", &wt_path, Some(&opts))
.unwrap();
assert!(wt_path.exists(), "Worktree path should exist on disk");
let wt_repo = git2::Repository::open(&wt_path).unwrap();
assert!(wt_repo.is_worktree(), "Should be a git worktree");
}
#[test]
fn test_cook_creates_worktree_for_issue() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("my-project");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let commit = repo.find_commit(commit_oid).unwrap();
repo.branch("issue-225", &commit, false).unwrap();
let wt_dir = tmp.path().join("worktrees");
let path_hash = short_path_hash(&repo_path);
let wt_path = wt_dir
.join(format!("my-project-{}", path_hash))
.join("issue-225");
std::fs::create_dir_all(wt_path.parent().unwrap()).unwrap();
let branch = repo
.find_branch("issue-225", git2::BranchType::Local)
.unwrap();
let reference = branch.into_reference();
let mut opts = git2::WorktreeAddOptions::new();
opts.reference(Some(&reference));
repo.worktree("enwiro-issue-225", &wt_path, Some(&opts))
.unwrap();
assert!(wt_path.exists(), "Worktree path should exist on disk");
let wt_repo = git2::Repository::open(&wt_path).unwrap();
assert!(wt_repo.is_worktree(), "Should be a git worktree");
}
fn setup_repo_with_origin(local_path: &Path, origin_path: &Path) -> git2::Repository {
let origin = git2::Repository::init_bare(origin_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = origin.index().unwrap().write_tree().unwrap();
let tree = origin.find_tree(tree_id).unwrap();
origin
.commit(Some("refs/heads/main"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let repo = git2::build::RepoBuilder::new()
.clone(origin_path.to_str().unwrap(), local_path)
.unwrap();
repo
}
#[test]
fn test_get_default_branch_uses_origin_head() {
let tmp = tempfile::TempDir::new().unwrap();
let origin_path = tmp.path().join("origin.git");
let local_path = tmp.path().join("local");
let repo = setup_repo_with_origin(&local_path, &origin_path);
let branch = get_default_branch(&repo).unwrap();
assert_eq!(branch, "main");
}
#[test]
fn test_get_default_branch_falls_back_to_main() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("repo");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
repo.remote("origin", "https://example.com/fake.git")
.unwrap();
let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
repo.reference(
"refs/remotes/origin/main",
head_commit.id(),
false,
"fake remote ref",
)
.unwrap();
let branch = get_default_branch(&repo).unwrap();
assert_eq!(branch, "main");
}
#[test]
fn test_get_default_branch_falls_back_to_master() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("repo");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
repo.remote("origin", "https://example.com/fake.git")
.unwrap();
let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
repo.reference(
"refs/remotes/origin/master",
head_commit.id(),
false,
"fake remote ref",
)
.unwrap();
let branch = get_default_branch(&repo).unwrap();
assert_eq!(branch, "master");
}
#[test]
fn test_parse_search_response_captures_updated_at() {
let json = r#"{
"data": {
"search": {
"nodes": [
{
"number": 42,
"title": "Fix the thing",
"headRefName": "fix-thing",
"updatedAt": "2026-02-14T13:10:29Z",
"repository": { "nameWithOwner": "kantord/enwiro" }
},
{
"number": 225,
"title": "Bug report",
"updatedAt": "2026-02-12T09:00:00Z",
"repository": { "nameWithOwner": "kantord/enwiro" }
}
]
}
}
}"#;
let items = parse_search_response(json).unwrap();
assert_eq!(items[0].updated_at, "2026-02-14T13:10:29Z");
assert_eq!(items[1].updated_at, "2026-02-12T09:00:00Z");
}
#[test]
fn test_list_recipes_sorts_combined_items_by_date() {
let mut items = vec![
GithubItem {
number: 10,
title: "Old PR".to_string(),
repo: "enwiro".to_string(),
kind: GithubItemKind::PullRequest {
head_ref_name: "old-pr".to_string(),
},
updated_at: "2026-02-01T00:00:00Z".to_string(),
},
GithubItem {
number: 20,
title: "Recent issue".to_string(),
repo: "enwiro".to_string(),
kind: GithubItemKind::Issue,
updated_at: "2026-02-15T00:00:00Z".to_string(),
},
GithubItem {
number: 30,
title: "Newest PR".to_string(),
repo: "enwiro".to_string(),
kind: GithubItemKind::PullRequest {
head_ref_name: "newest-pr".to_string(),
},
updated_at: "2026-02-18T00:00:00Z".to_string(),
},
];
sort_items_by_date(&mut items);
assert_eq!(items[0].number, 30, "Newest PR should be first");
assert_eq!(items[1].number, 20, "Recent issue should be second");
assert_eq!(items[2].number, 10, "Old PR should be last");
}
#[test]
fn test_get_default_branch_errors_when_no_candidates() {
let tmp = tempfile::TempDir::new().unwrap();
let repo_path = tmp.path().join("repo");
std::fs::create_dir(&repo_path).unwrap();
let repo = git2::Repository::init(&repo_path).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
repo.remote("origin", "https://example.com/fake.git")
.unwrap();
let result = get_default_branch(&repo);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Could not determine default branch"),
"Expected helpful error, got: {}",
err
);
}
#[test]
fn test_compute_sort_order_single_item() {
assert_eq!(compute_sort_order(0, 1), 0);
}
#[test]
fn test_compute_sort_order_two_items() {
assert_eq!(compute_sort_order(0, 2), 0);
assert_eq!(compute_sort_order(1, 2), 100);
}
#[test]
fn test_compute_sort_order_three_items() {
assert_eq!(compute_sort_order(0, 3), 0);
assert_eq!(compute_sort_order(1, 3), 50);
assert_eq!(compute_sort_order(2, 3), 100);
}
}
fn main() -> anyhow::Result<()> {
let _guard = enwiro_logging::init_logging("enwiro-cookbook-github.log");
let args = EnwiroCookbookGithub::parse();
let config: ConfigurationValues =
confy::load("enwiro", "cookbook-github").context("Could not load configuration")?;
tracing::debug!("Config loaded, repos will be auto-discovered from git cookbook");
match args {
EnwiroCookbookGithub::ListRecipes(_) => {
list_recipes()?;
}
EnwiroCookbookGithub::Cook(args) => {
cook(&config, args)?;
}
EnwiroCookbookGithub::Metadata => {
println!(r#"{{"defaultPriority":30}}"#);
}
};
Ok(())
}