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;
use crate::display_options;
use crate::text::truncate;
use crate::vcs::{generate_branch_name, git_branch_exists, run_git_command, validate_branch_name};
#[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 {
#[command(after_help = r#"EXAMPLES:
linear git checkout LIN-123 # Checkout issue branch
linear g checkout LIN-123 -b feature/fix # Use custom branch name
linear g checkout LIN-123 --vcs jj # Use Jujutsu VCS"#)]
Checkout {
issue: String,
#[arg(short, long)]
branch: Option<String>,
#[arg(long, value_enum)]
vcs: Option<Vcs>,
},
#[command(after_help = r#"EXAMPLES:
linear git branch LIN-123 # Show branch name
linear g branch LIN-123 --vcs git # Show git branch name"#)]
Branch {
issue: String,
#[arg(long, value_enum)]
vcs: Option<Vcs>,
},
#[command(after_help = r#"EXAMPLES:
linear git create LIN-123 # Create branch
linear g create LIN-123 -b custom-branch # Create with custom name"#)]
Create {
issue: String,
#[arg(short, long)]
branch: Option<String>,
#[arg(long, value_enum)]
vcs: Option<Vcs>,
},
#[command(after_help = r#"EXAMPLES:
linear git commits # Show last 10 commits
linear g commits -l 20 # Show last 20 commits"#)]
Commits {
#[arg(short, long, default_value = "10")]
limit: usize,
#[arg(long, value_enum)]
vcs: Option<Vcs>,
},
#[command(after_help = r#"EXAMPLES:
linear git pr LIN-123 # Create PR for issue
linear g pr LIN-123 --draft # Create draft PR
linear g pr LIN-123 -B develop # Merge into develop
linear g pr LIN-123 --web # Open PR in browser"#)]
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 extract_linear_issue(message: &str) -> Option<String> {
if let Some(line) = message.lines().find(|l| l.starts_with("Linear-Issue:")) {
return line
.strip_prefix("Linear-Issue:")
.map(|s| s.trim().to_string());
}
let re_bracket = regex::Regex::new(r"\[([A-Z]+-\d+)\]").ok()?;
if let Some(caps) = re_bracket.captures(message) {
return caps.get(1).map(|m| m.as_str().to_string());
}
if let Some(pos) = message.find("linear.app/") {
let after = &message[pos..];
let re_url = regex::Regex::new(r"([A-Z]+-\d+)").ok()?;
if let Some(caps) = re_url.captures(after) {
return caps.get(1).map(|m| m.as_str().to_string());
}
}
None
}
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 => git_branch_exists(branch),
Vcs::Jj => {
if validate_branch_name(branch).is_err() {
return false;
}
run_jj_command(&["bookmark", "list", branch])
.is_ok_and(|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 title_width = display_options().max_width(50);
let branch_name = if let Some(custom_branch) = custom_branch {
validate_branch_name(&custom_branch)?;
custom_branch
} else if !linear_branch.is_empty() && validate_branch_name(&linear_branch).is_ok() {
linear_branch
} else {
generate_branch_name(&identifier, &title)
};
println!(
"{} {} {}",
identifier.cyan(),
truncate(&title, title_width).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?;
let title_width = display_options().max_width(50);
println!(
"{} {} {}",
identifier.cyan().bold(),
truncate(&title, title_width),
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 title_width = display_options().max_width(50);
let branch_name = if let Some(custom_branch) = custom_branch {
validate_branch_name(&custom_branch)?;
custom_branch
} else if !linear_branch.is_empty() && validate_branch_name(&linear_branch).is_ok() {
linear_branch
} else {
generate_branch_name(&identifier, &title)
};
println!(
"{} {} {}",
identifier.cyan(),
truncate(&title, title_width).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 => {
let subj_width = display_options().max_width(60);
println!("{}", "Commits with Linear references:".cyan().bold());
println!("{}", "-".repeat(50));
let limit_str = limit.to_string();
let output =
run_git_command(&["log", &format!("-{}", limit_str), "--format=%H|%s|%b%x00"])?;
for entry in output.split('\0') {
if entry.trim().is_empty() {
continue;
}
let parts: Vec<&str> = entry.splitn(3, '|').collect();
if parts.len() < 2 {
continue;
}
let hash = &parts[0][..8]; let subject = parts[1];
let body = parts.get(2).unwrap_or(&"");
let full_message = format!("{} {}", subject, body);
let has_linear_ref = full_message.contains("Linear-Issue:")
|| full_message.contains("Linear-URL:")
|| full_message.contains("linear.app/")
|| subject.contains('[') && subject.contains('-');
if has_linear_ref {
let issue_id = extract_linear_issue(&full_message);
if let Some(id) = issue_id {
let subject = truncate(subject, subj_width);
println!(
"{} {} {}",
hash.yellow(),
subject,
format!("[{}]", id).cyan()
);
} else {
let subject = truncate(subject, subj_width);
println!("{} {}", hash.yellow(), subject);
}
} else {
let subject = truncate(subject, subj_width);
println!("{} {}", hash.dimmed(), subject);
}
}
Ok(())
}
Vcs::Jj => {
let desc_width = display_options().max_width(60);
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(),
truncate(description, desc_width),
format!("[{}]", issue_id).cyan()
);
} else {
println!(
"{} {}",
change_id.dimmed(),
truncate(description, desc_width)
);
}
}
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 title_width = display_options().max_width(60);
let pr_title = format!("[{}] {}", identifier, title);
let pr_body = format!("Linear: {}", url);
println!(
"{} {}",
identifier.cyan(),
truncate(&title, title_width).dimmed()
);
println!(
"Creating PR with title: {}",
truncate(&pr_title, title_width).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(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_branch_name_simple() {
assert_eq!(
generate_branch_name("LIN-123", "Fix bug"),
"lin-123/fix-bug"
);
}
#[test]
fn test_generate_branch_name_special_chars() {
assert_eq!(
generate_branch_name("LIN-456", "Add feature: user auth!"),
"lin-456/add-feature-user-auth"
);
}
#[test]
fn test_generate_branch_name_long_title() {
let long_title = "This is a very long title that should be truncated because it exceeds the maximum length";
let result = generate_branch_name("LIN-789", long_title);
assert!(result.len() <= 58, "Branch name too long: {}", result);
assert!(result.starts_with("lin-789/"));
}
#[test]
fn test_generate_branch_name_unicode() {
let result = generate_branch_name("LIN-100", "Fix emoji 🎉 handling");
assert_eq!(result, "lin-100/fix-emoji-handling");
}
#[test]
fn test_generate_branch_name_multiple_spaces() {
assert_eq!(
generate_branch_name("ENG-42", "Fix multiple spaces"),
"eng-42/fix-multiple-spaces"
);
}
#[test]
fn test_generate_branch_name_leading_trailing_special() {
assert_eq!(
generate_branch_name("DEV-1", " --Fix bug-- "),
"dev-1/fix-bug"
);
}
}