use anyhow::Result;
use clap::{Subcommand, ValueEnum};
use colored::Colorize;
use serde_json::json;
use std::path::Path;
use std::process::Command;
use crate::api::LinearClient;
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Vcs {
Git,
Jj,
}
impl std::fmt::Display for Vcs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Vcs::Git => write!(f, "git"),
Vcs::Jj => write!(f, "jj"),
}
}
}
#[derive(Subcommand)]
pub enum GitCommands {
Checkout {
issue: String,
#[arg(short, long)]
branch: Option<String>,
#[arg(long, value_enum)]
vcs: Option<Vcs>,
},
Branch {
issue: String,
#[arg(long, value_enum)]
vcs: Option<Vcs>,
},
Create {
issue: String,
#[arg(short, long)]
branch: Option<String>,
#[arg(long, value_enum)]
vcs: Option<Vcs>,
},
Commits {
#[arg(short, long, default_value = "10")]
limit: usize,
#[arg(long, value_enum)]
vcs: Option<Vcs>,
},
Pr {
issue: String,
#[arg(short = 'B', long, default_value = "main")]
base: String,
#[arg(short, long)]
draft: bool,
#[arg(short, long)]
web: bool,
},
}
fn detect_vcs() -> Result<Vcs> {
if Path::new(".jj").exists() {
return Ok(Vcs::Jj);
}
if let Ok(output) = Command::new("jj").args(["status"]).output() {
if output.status.success() {
return Ok(Vcs::Jj);
}
}
if Path::new(".git").exists() {
return Ok(Vcs::Git);
}
if let Ok(output) = Command::new("git").args(["status"]).output() {
if output.status.success() {
return Ok(Vcs::Git);
}
}
anyhow::bail!("Not in a git or jj repository")
}
fn get_vcs(vcs_flag: Option<Vcs>) -> Result<Vcs> {
match vcs_flag {
Some(vcs) => Ok(vcs),
None => detect_vcs(),
}
}
pub async fn handle(cmd: GitCommands) -> Result<()> {
match cmd {
GitCommands::Checkout { issue, branch, vcs } => {
let vcs = get_vcs(vcs)?;
checkout_issue(&issue, branch, vcs).await
}
GitCommands::Branch { issue, vcs } => {
let vcs = get_vcs(vcs)?;
show_branch(&issue, vcs).await
}
GitCommands::Create { issue, branch, vcs } => {
let vcs = get_vcs(vcs)?;
create_branch(&issue, branch, vcs).await
}
GitCommands::Commits { limit, vcs } => {
let vcs = get_vcs(vcs)?;
show_commits(limit, vcs).await
}
GitCommands::Pr { issue, base, draft, web } => {
create_pr(&issue, &base, draft, web).await
}
}
}
async fn get_issue_info(issue_id: &str) -> Result<(String, String, String, String)> {
let client = LinearClient::new()?;
let query = r#"
query($id: String!) {
issue(id: $id) {
id
identifier
title
branchName
url
}
}
"#;
let result = client.query(query, Some(json!({ "id": issue_id }))).await?;
let issue = &result["data"]["issue"];
if issue.is_null() {
anyhow::bail!("Issue not found: {}", issue_id);
}
let identifier = issue["identifier"].as_str().unwrap_or("").to_string();
let title = issue["title"].as_str().unwrap_or("").to_string();
let branch_name = issue["branchName"].as_str().unwrap_or("").to_string();
let url = issue["url"].as_str().unwrap_or("").to_string();
Ok((identifier, title, branch_name, url))
}
fn generate_branch_name(identifier: &str, title: &str) -> String {
let slug: String = title
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
let slug = if slug.len() > 50 {
slug[..50].trim_end_matches('-').to_string()
} else {
slug
};
format!("{}/{}", identifier.to_lowercase(), slug)
}
fn run_git_command(args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Git command failed: {}", stderr.trim());
}
}
fn run_jj_command(args: &[&str]) -> Result<String> {
let output = Command::new("jj")
.args(args)
.output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Jujutsu command failed: {}", stderr.trim());
}
}
fn branch_exists(branch: &str, vcs: Vcs) -> bool {
match vcs {
Vcs::Git => run_git_command(&["rev-parse", "--verify", branch]).is_ok(),
Vcs::Jj => {
run_jj_command(&["bookmark", "list", branch]).map_or(false, |output| {
output.lines().any(|line| line.starts_with(branch))
})
}
}
}
fn get_current_branch(vcs: Vcs) -> Result<String> {
match vcs {
Vcs::Git => run_git_command(&["rev-parse", "--abbrev-ref", "HEAD"]),
Vcs::Jj => {
run_jj_command(&["log", "-r", "@", "--no-graph", "-T", "change_id.short()"])
}
}
}
fn generate_jj_description(identifier: &str, title: &str, url: &str) -> String {
format!(
"{}: {}\n\nLinear-Issue: {}\nLinear-URL: {}",
identifier, title, identifier, url
)
}
async fn checkout_issue(issue_id: &str, custom_branch: Option<String>, vcs: Vcs) -> Result<()> {
let (identifier, title, linear_branch, url) = get_issue_info(issue_id).await?;
let branch_name = custom_branch
.or_else(|| if linear_branch.is_empty() { None } else { Some(linear_branch) })
.unwrap_or_else(|| generate_branch_name(&identifier, &title));
println!("{} {} {}", identifier.cyan(), title.dimmed(), format!("({})", vcs).dimmed());
match vcs {
Vcs::Git => {
if branch_exists(&branch_name, vcs) {
println!("Checking out existing branch: {}", branch_name.green());
run_git_command(&["checkout", &branch_name])?;
} else {
println!("Creating and checking out branch: {}", branch_name.green());
run_git_command(&["checkout", "-b", &branch_name])?;
}
let current = get_current_branch(vcs)?;
println!("{} Now on branch: {}", "✓".green(), current);
}
Vcs::Jj => {
let description = generate_jj_description(&identifier, &title, &url);
if branch_exists(&branch_name, vcs) {
println!("Switching to existing bookmark: {}", branch_name.green());
run_jj_command(&["edit", &branch_name])?;
} else {
println!("Creating new change for issue: {}", identifier.green());
run_jj_command(&["new", "-m", &description])?;
println!("Creating bookmark: {}", branch_name.green());
run_jj_command(&["bookmark", "create", &branch_name])?;
}
let current = get_current_branch(vcs)?;
println!("{} Now on change: {}", "✓".green(), current);
}
}
Ok(())
}
async fn show_branch(issue_id: &str, vcs: Vcs) -> Result<()> {
let (identifier, title, linear_branch, url) = get_issue_info(issue_id).await?;
println!("{} {} {}", identifier.cyan().bold(), title, format!("({})", vcs).dimmed());
println!("{}", "-".repeat(50));
if !linear_branch.is_empty() {
println!("Linear branch: {}", linear_branch.green());
}
let generated = generate_branch_name(&identifier, &title);
println!("Generated: {}", generated.yellow());
println!("Issue URL: {}", url.blue());
match vcs {
Vcs::Git => {
if branch_exists(&linear_branch, vcs) {
println!("\n{} Linear branch exists locally", "✓".green());
} else if branch_exists(&generated, vcs) {
println!("\n{} Generated branch exists locally", "✓".green());
} else {
println!("\n{} No local branch found for this issue", "!".yellow());
}
}
Vcs::Jj => {
if branch_exists(&linear_branch, vcs) {
println!("\n{} Linear bookmark exists", "✓".green());
} else if branch_exists(&generated, vcs) {
println!("\n{} Generated bookmark exists", "✓".green());
} else {
println!("\n{} No bookmark found for this issue", "!".yellow());
}
}
}
Ok(())
}
async fn create_branch(issue_id: &str, custom_branch: Option<String>, vcs: Vcs) -> Result<()> {
let (identifier, title, linear_branch, url) = get_issue_info(issue_id).await?;
let branch_name = custom_branch
.or_else(|| if linear_branch.is_empty() { None } else { Some(linear_branch) })
.unwrap_or_else(|| generate_branch_name(&identifier, &title));
println!("{} {} {}", identifier.cyan(), title.dimmed(), format!("({})", vcs).dimmed());
match vcs {
Vcs::Git => {
if branch_exists(&branch_name, vcs) {
println!("{} Branch already exists: {}", "!".yellow(), branch_name);
return Ok(());
}
run_git_command(&["branch", &branch_name])?;
println!("{} Created branch: {}", "✓".green(), branch_name);
}
Vcs::Jj => {
if branch_exists(&branch_name, vcs) {
println!("{} Bookmark already exists: {}", "!".yellow(), branch_name);
return Ok(());
}
let description = generate_jj_description(&identifier, &title, &url);
run_jj_command(&["new", "-m", &description])?;
run_jj_command(&["bookmark", "create", &branch_name])?;
run_jj_command(&["prev"])?;
println!("{} Created bookmark: {}", "✓".green(), branch_name);
}
}
Ok(())
}
async fn show_commits(limit: usize, vcs: Vcs) -> Result<()> {
match vcs {
Vcs::Git => {
println!("{}", "The 'commits' subcommand is designed for jj. For git, use 'git log'.".yellow());
println!("Tip: Use --vcs jj to explicitly use jj commands.");
Ok(())
}
Vcs::Jj => {
println!("{}", "Commits with Linear issue trailers:".cyan().bold());
println!("{}", "-".repeat(50));
let limit_str = limit.to_string();
let output = run_jj_command(&[
"log",
"-r", &format!("ancestors(@, {})", limit_str),
"--no-graph",
"-T", r#"change_id.short() ++ " " ++ description.first_line() ++ "\n""#,
])?;
for line in output.lines() {
if line.trim().is_empty() {
continue;
}
let parts: Vec<&str> = line.splitn(2, ' ').collect();
let (change_id, description) = if parts.len() == 2 {
(parts[0], parts[1])
} else {
(parts[0], "")
};
let full_desc = run_jj_command(&[
"log",
"-r", change_id,
"--no-graph",
"-T", "description",
])?;
let has_linear_trailer = full_desc.contains("Linear-Issue:") ||
full_desc.contains("Linear-URL:");
if has_linear_trailer {
let issue_id = full_desc
.lines()
.find(|l| l.starts_with("Linear-Issue:"))
.and_then(|l| l.strip_prefix("Linear-Issue:"))
.map(|s| s.trim())
.unwrap_or("");
println!(
"{} {} {}",
change_id.yellow(),
description,
format!("[{}]", issue_id).cyan()
);
} else {
println!("{} {}", change_id.dimmed(), description);
}
}
Ok(())
}
}
}
fn run_gh_command(args: &[&str]) -> Result<String> {
let output = Command::new("gh")
.args(args)
.output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("gh command failed: {}", stderr.trim());
}
}
async fn create_pr(issue_id: &str, base: &str, draft: bool, web: bool) -> Result<()> {
let (identifier, title, _branch_name, url) = get_issue_info(issue_id).await?;
let pr_title = format!("[{}] {}", identifier, title);
let pr_body = format!("Linear: {}", url);
println!("{} {}", identifier.cyan(), title.dimmed());
println!("Creating PR with title: {}", pr_title.green());
let mut args = vec!["pr", "create", "--title", &pr_title, "--body", &pr_body, "--base", base];
if draft {
args.push("--draft");
}
if web {
args.push("--web");
}
let result = run_gh_command(&args)?;
if !result.is_empty() {
println!("{} PR created: {}", "✓".green(), result);
} else {
println!("{} PR created successfully!", "✓".green());
}
Ok(())
}