diff --git a/Cargo.lock b/Cargo.lock index 66b6f25a7..f0f34dce3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1884,6 +1884,7 @@ dependencies = [ name = "josh-cli" version = "22.4.15" dependencies = [ + "anyhow", "clap", "defer", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index dd29b77b5..d26509e7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ opt-level = 3 codegen-units = 1 [workspace.dependencies] +anyhow = "1.0" base64 = "0.22.1" defer = "0.2.1" env_logger = "0.11.5" diff --git a/josh-cli/Cargo.toml b/josh-cli/Cargo.toml index d152b24a9..9283a5ea2 100644 --- a/josh-cli/Cargo.toml +++ b/josh-cli/Cargo.toml @@ -13,6 +13,7 @@ version = "22.4.15" josh = { path = "../josh-core" } josh-graphql = { path = "../josh-graphql" } josh-templates = { path = "../josh-templates" } +anyhow = { workspace = true } env_logger = { workspace = true } log = { workspace = true } serde_json = { workspace = true } diff --git a/josh-cli/src/bin/josh.rs b/josh-cli/src/bin/josh.rs index c4a72ed8c..66d3b5447 100644 --- a/josh-cli/src/bin/josh.rs +++ b/josh-cli/src/bin/josh.rs @@ -1,24 +1,30 @@ #![warn(unused_extern_crates)] +use anyhow::Context; use clap::Parser; use josh::changes::{PushMode, build_to_push}; use josh::shell::Shell; -use log::debug; + use std::io::IsTerminal; use std::process::{Command as ProcessCommand, Stdio}; +/// Helper function to convert josh::JoshError to anyhow::Error +fn from_josh_err(err: josh::JoshError) -> anyhow::Error { + anyhow::anyhow!("{}", err.0) +} + /// Spawn a git command directly to the terminal so users can see progress /// Falls back to captured output if not in a TTY environment fn spawn_git_command( cwd: &std::path::Path, args: &[&str], env: &[(&str, &str)], -) -> Result> { - debug!("spawn_git_command: {:?}", args); +) -> anyhow::Result<()> { + log::debug!("spawn_git_command: {:?}", args); + let mut command = ProcessCommand::new("git"); command.current_dir(cwd).args(args); - // Add environment variables for (key, value) in env { command.env(key, value); } @@ -26,15 +32,14 @@ fn spawn_git_command( // Check if we're in a TTY environment let is_tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); - if is_tty { + let status = if is_tty { // In TTY: inherit stdio so users can see progress command .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); - let status = command.status()?; - Ok(status.code().unwrap_or(1)) + command.status()?.code() } else { // Not in TTY: capture output and print stderr (for tests, CI, etc.) // Use the same approach as josh::shell::Shell for consistency @@ -43,21 +48,33 @@ fn spawn_git_command( .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() - .map_err(|e| format!("failed to execute git command: {}", e))?; + .context("failed to execute git command")?; // Print stderr if there's any output if !output.stderr.is_empty() { let output_str = String::from_utf8_lossy(&output.stderr); let output_str = if let Ok(testtmp) = std::env::var("TESTTMP") { - //println!("TESTTMP {:?}", testtmp); - output_str.replace(&testtmp, "$TESTTMP") + output_str.replace(&testtmp, "${TESTTMP}") } else { output_str.to_string() }; + eprintln!("{}", output_str); } - Ok(output.status.code().unwrap_or(1)) + output.status.code() + }; + + match status.unwrap_or(1) { + 0 => Ok(()), + code => { + let command = args.join(" "); + Err(anyhow::anyhow!( + "Command exited with code {}: git {}", + code, + command + )) + } } } @@ -229,76 +246,55 @@ fn main() { env_logger::init(); let cli = Cli::parse(); - match &cli.command { - Command::Clone(args) => { - if let Err(e) = handle_clone(args) { - eprintln!("Error: {}", e); - std::process::exit(1); - } - } - Command::Fetch(args) => { - if let Err(e) = handle_fetch(args) { - eprintln!("Error: {}", e); - std::process::exit(1); - } - } - Command::Pull(args) => { - if let Err(e) = handle_pull(args) { - eprintln!("Error: {}", e); - std::process::exit(1); - } - } - Command::Push(args) => { - if let Err(e) = handle_push(args) { - eprintln!("Error: {}", e); - std::process::exit(1); - } - } - Command::Remote(args) => { - if let Err(e) = handle_remote(args) { - eprintln!("Error: {}", e); - std::process::exit(1); - } - } - Command::Filter(args) => { - if let Err(e) = handle_filter(args) { - eprintln!("Error: {}", e); - std::process::exit(1); - } + let result = match &cli.command { + Command::Clone(args) => handle_clone(args), + Command::Fetch(args) => handle_fetch(args), + Command::Pull(args) => handle_pull(args), + Command::Push(args) => handle_push(args), + Command::Remote(args) => handle_remote(args), + Command::Filter(args) => handle_filter(args), + }; + + if let Err(e) = result { + eprintln!("Error: {e}"); + + for e in e.chain() { + eprintln!("{e}"); } + + std::process::exit(1); } } /// Apply josh filtering to all remote refs and update local refs fn apply_josh_filtering( - repo_shell: &Shell, + repo_path: &std::path::Path, filter: &str, remote_name: &str, -) -> Result<(), Box> { - // Change to the repository directory - let original_dir = std::env::current_dir()?; - std::env::set_current_dir(repo_shell.cwd.as_path())?; - +) -> anyhow::Result<()> { // Use josh API directly instead of calling josh-filter binary - let filterobj = - josh::filter::parse(filter).map_err(|e| format!("Failed to parse filter: {}", e.0))?; + let filterobj = josh::filter::parse(filter) + .map_err(from_josh_err) + .context("Failed to parse filter")?; - josh::cache_sled::sled_load(&repo_shell.cwd.as_path().join(".git")).unwrap(); + josh::cache_sled::sled_load(&repo_path.join(".git")) + .map_err(from_josh_err) + .context("Failed to load sled cache")?; let cache = std::sync::Arc::new( josh::cache_stack::CacheStack::new() .with_backend(josh::cache_sled::SledCacheBackend::default()) .with_backend( - josh::cache_notes::NotesCacheBackend::new(&repo_shell.cwd.as_path()) - .map_err(|e| format!("Failed to create NotesCacheBackend: {}", e.0))?, + josh::cache_notes::NotesCacheBackend::new(repo_path) + .map_err(from_josh_err) + .context("Failed to create NotesCacheBackend")?, ), ); // Open Josh transaction - let transaction = josh::cache::TransactionContext::from_env(cache.clone()) - .map_err(|e| format!("Failed TransactionContext::from_env: {}", e.0))? + let transaction = josh::cache::TransactionContext::new(repo_path, cache.clone()) .open(None) - .map_err(|e| format!("Failed TransactionContext::open: {}", e.0))?; + .map_err(from_josh_err)?; let repo = transaction.repo(); @@ -315,7 +311,7 @@ fn apply_josh_filtering( } if input_refs.is_empty() { - return Err("No remote references found".into()); + return Err(anyhow::anyhow!("No remote references found")); } // Apply the filter to all remote refs @@ -324,7 +320,7 @@ fn apply_josh_filtering( // Check for errors for error in errors { - return Err(format!("josh filter error: {}", error.1.0).into()); + return Err(anyhow::anyhow!("josh filter error: {}", error.1.0)); } // Second pass: create all references @@ -338,35 +334,43 @@ fn apply_josh_filtering( // Extract branch name from refs/josh/remotes/{remote_name}/branch_name let branch_name = original_ref .strip_prefix(&format!("refs/josh/remotes/{}/", remote_name)) - .ok_or("Invalid josh remote reference")?; + .context("Invalid josh remote reference")?; // Create filtered reference in josh/filtered namespace let filtered_ref = format!( "refs/namespaces/josh-{}/refs/heads/{}", remote_name, branch_name ); + repo.reference(&filtered_ref, filtered_oid, true, "josh filter") - .map_err(|e| format!("Failed to create filtered reference: {}", e))?; + .context("failed to create filtered reference")?; } - // Fetch the filtered refs to create standard remote refs - let path_env = std::env::var("PATH").unwrap_or_default(); - let exit_code = spawn_git_command( - repo_shell.cwd.as_path(), - &["fetch", remote_name], - &[("PATH", &path_env)], - )?; + spawn_git_command(repo_path, &["fetch", remote_name], &[]) + .context("failed to fetch filtered refs")?; - if exit_code != 0 { - return Err(format!("Failed to fetch filtered refs: exit code {}", exit_code).into()); - } - - // Restore the original directory - std::env::set_current_dir(original_dir)?; Ok(()) } -fn handle_clone(args: &CloneArgs) -> Result<(), Box> { +fn to_absolute_remote_url(url: &str) -> anyhow::Result { + if url.starts_with("http://") + || url.starts_with("https://") + || url.starts_with("ssh://") + || url.starts_with("file://") + { + Ok(url.to_owned()) + } else { + // For local paths, make them absolute + let path = std::fs::canonicalize(&url) + .with_context(|| format!("Failed to resolve path {}", url))? + .display() + .to_string(); + + Ok(format!("file://{}", path)) + } +} + +fn handle_clone(args: &CloneArgs) -> anyhow::Result<()> { // Use the provided output directory let output_dir = args.out.clone(); @@ -374,36 +378,17 @@ fn handle_clone(args: &CloneArgs) -> Result<(), Box> { std::fs::create_dir_all(&output_dir)?; // Initialize a new git repository inside the directory using git2 - let _repo = git2::Repository::init(&output_dir) - .map_err(|e| format!("Failed to initialize git repository: {}", e))?; - - // Change to the repository directory and add the remote using handle_remote_add - let original_dir = std::env::current_dir()?; - std::env::set_current_dir(&output_dir)?; - - // Make the URL absolute if it's a relative path (for local repositories) - let absolute_url = if args.url.starts_with("http") || args.url.starts_with("ssh://") { - args.url.clone() - } else { - // For local paths, make them absolute relative to the original directory - let absolute_path = if args.url.starts_with('/') { - // Already absolute - args.url.clone() - } else { - // Relative to original directory - original_dir.join(&args.url).to_string_lossy().to_string() - }; - absolute_path - }; + git2::Repository::init(&output_dir).context("Failed to initialize git repository")?; // Use handle_remote_add to add the remote with the filter let remote_add_args = RemoteAddArgs { name: "origin".to_string(), - url: absolute_url, + url: to_absolute_remote_url(&args.url)?, filter: args.filter.clone(), keep_trivial_merges: args.keep_trivial_merges, }; - handle_remote_add(&remote_add_args)?; + + handle_remote_add_repo(&remote_add_args, &output_dir)?; // Create FetchArgs from CloneArgs let fetch_args = FetchArgs { @@ -413,89 +398,65 @@ fn handle_clone(args: &CloneArgs) -> Result<(), Box> { }; // Use handle_fetch to do the actual fetching and filtering - handle_fetch(&fetch_args)?; + handle_fetch_repo(&fetch_args, &output_dir)?; // Get the default branch name from the remote HEAD symref let default_branch = if args.branch == "HEAD" { // Read the remote HEAD symref to get the default branch - let head_ref = format!("refs/remotes/origin/HEAD"); - let repo = git2::Repository::open_from_env() - .map_err(|e| format!("Not in a git repository: {}", e))?; + let head_ref = "refs/remotes/origin/HEAD".to_string(); + let repo = git2::Repository::open(&output_dir).context("Failed to open repository")?; let head_reference = repo .find_reference(&head_ref) - .map_err(|e| format!("Failed to find remote HEAD reference {}: {}", head_ref, e))?; + .with_context(|| format!("Failed to find remote HEAD reference {}", head_ref))?; let symref_target = head_reference .symbolic_target() - .ok_or("Remote HEAD reference is not a symbolic reference")?; + .context("Remote HEAD reference is not a symbolic reference")?; // Extract branch name from symref target (e.g., "refs/remotes/origin/master" -> "master") let branch_name = symref_target .strip_prefix("refs/remotes/origin/") - .ok_or_else(|| format!("Invalid symref target format: {}", symref_target))?; + .with_context(|| format!("Invalid symref target format: {}", symref_target))?; branch_name.to_string() } else { args.branch.clone() }; - // Checkout the default branch - let path_env = std::env::var("PATH").unwrap_or_default(); - let repo_shell = Shell { - cwd: std::env::current_dir()?, - }; - - let exit_code = spawn_git_command( - repo_shell.cwd.as_path(), + spawn_git_command( + &output_dir, &[ "checkout", "-b", &default_branch, &format!("origin/{}", default_branch), ], - &[("PATH", &path_env)], - )?; - - if exit_code != 0 { - return Err(format!( - "Failed to checkout branch {}: exit code {}", - default_branch, exit_code - ) - .into()); - } + &[], + ) + .with_context(|| format!("Failed to checkout branch {}", default_branch))?; // Set up upstream tracking for the branch - let exit_code = spawn_git_command( - repo_shell.cwd.as_path(), + spawn_git_command( + &output_dir, &[ "branch", "--set-upstream-to", &format!("origin/{}", default_branch), &default_branch, ], - &[("PATH", &path_env)], - )?; - - if exit_code != 0 { - return Err(format!( - "Failed to set upstream for branch {}: exit code {}", - default_branch, exit_code - ) - .into()); - } - - // Restore the original directory - std::env::set_current_dir(original_dir)?; + &[], + ) + .with_context(|| format!("Failed to set upstream for branch {}", default_branch))?; println!("Cloned repository to: {}", output_dir.display()); Ok(()) } -fn handle_pull(args: &PullArgs) -> Result<(), Box> { +fn handle_pull(args: &PullArgs) -> anyhow::Result<()> { // Check if we're in a git repository - let _repo = - git2::Repository::open_from_env().map_err(|e| format!("Not in a git repository: {}", e))?; + let repo = git2::Repository::open_from_env().context("Not in a git repository")?; + let repo_path = repo.path().parent().unwrap().to_path_buf(); // Create FetchArgs from PullArgs let fetch_args = FetchArgs { @@ -505,135 +466,95 @@ fn handle_pull(args: &PullArgs) -> Result<(), Box> { }; // Use handle_fetch to do the actual fetching and filtering - handle_fetch(&fetch_args)?; + handle_fetch_repo(&fetch_args, &repo_path)?; // Get current working directory for shell commands let current_dir = std::env::current_dir()?; let repo_shell = Shell { cwd: current_dir.clone(), }; - let path_env = std::env::var("PATH").unwrap_or_default(); // Now use actual git pull to integrate the changes - let mut git_cmd = vec!["git", "pull"]; + let mut git_args = vec!["pull"]; - // Add flags based on arguments if args.rebase { - git_cmd.push("--rebase"); + git_args.push("--rebase"); } if args.autostash { - git_cmd.push("--autostash"); + git_args.push("--autostash"); } - // Add the remote and branch in the format: git pull {remote} {remote}/{branch} - //let remote_branch = format!("{}/{}", args.remote, default_branch); - git_cmd.push(&args.remote); - //git_cmd.push(&remote_branch); + git_args.push(&args.remote); - // Execute the git pull command - let exit_code = spawn_git_command( - repo_shell.cwd.as_path(), - &git_cmd[1..], // Skip "git" since spawn_git_command adds it - &[("PATH", &path_env)], - )?; + spawn_git_command(repo_shell.cwd.as_path(), &git_args, &[]).context("git pull failed")?; - if exit_code != 0 { - return Err(format!("git pull failed with exit code: {}", exit_code).into()); - } + eprintln!("Pulled from remote: {}", args.remote); - println!("Pulled from remote: {}", args.remote); Ok(()) } -fn handle_fetch(args: &FetchArgs) -> Result<(), Box> { +fn handle_fetch(args: &FetchArgs) -> anyhow::Result<()> { // Check if we're in a git repository - let repo = - git2::Repository::open_from_env().map_err(|e| format!("Not in a git repository: {}", e))?; + let repo = git2::Repository::open_from_env().context("Not in a git repository")?; + let repo_path = repo.path().parent().unwrap().to_path_buf(); - // Get current working directory (should be inside a git repository) - let current_dir = std::env::current_dir()?; + handle_fetch_repo(args, &repo_path) +} - // Create shell for the current repository directory - let repo_shell = Shell { - cwd: current_dir.clone(), - }; +fn try_parse_symref(remote: &str, output: &str) -> Option<(String, String)> { + let line = output.lines().next()?; + let symref_part = line.split('\t').next()?; - // Get PATH environment variable for shell commands - let path_env = std::env::var("PATH").unwrap_or_default(); + let default_branch = symref_part.strip_prefix("ref: refs/heads/")?; + let default_branch_ref = format!("refs/remotes/{}/{}", remote, default_branch); + + Some((default_branch.to_string(), default_branch_ref)) +} + +fn handle_fetch_repo(args: &FetchArgs, repo_path: &std::path::Path) -> anyhow::Result<()> { + let repo = git2::Repository::open(repo_path).context("Failed to open repository")?; // Read the remote URL from josh-remote config - let config = repo - .config() - .map_err(|e| format!("Failed to get git config: {}", e))?; + let config = repo.config().context("Failed to get git config")?; let remote_url = config .get_string(&format!("josh-remote.{}.url", args.remote)) - .map_err(|e| format!("Failed to get remote URL for '{}': {}", args.remote, e))?; + .with_context(|| format!("Failed to get remote URL for '{}'", args.remote))?; let refspec = config .get_string(&format!("josh-remote.{}.fetch", args.remote)) - .map_err(|e| format!("Failed to get refspec for '{}': {}", args.remote, e))?; + .with_context(|| format!("Failed to get refspec for '{}'", args.remote))?; // First, fetch unfiltered refs to refs/josh/remotes/* - let exit_code = spawn_git_command( - repo_shell.cwd.as_path(), - &["fetch", &remote_url, &refspec], - &[("PATH", &path_env)], - )?; - - if exit_code != 0 { - return Err(format!( - "git fetch to josh/remotes failed with exit code: {}", - exit_code - ) - .into()); - } + spawn_git_command(repo_path, &["fetch", &remote_url, &refspec], &[]) + .context("git fetch to josh/remotes failed")?; // Set up remote HEAD reference using git ls-remote // This is the proper way to get the default branch from the remote let head_ref = format!("refs/remotes/{}/HEAD", args.remote); // Use git ls-remote --symref to get the default branch - let exit_code = spawn_git_command( - repo_shell.cwd.as_path(), - &["ls-remote", "--symref", &remote_url, "HEAD"], - &[("PATH", &path_env)], - )?; - - if exit_code == 0 { - // Parse the output to get the default branch name - // Output format: "ref: refs/heads/main\t" - let output = std::process::Command::new("git") - .args(&["ls-remote", "--symref", &remote_url, "HEAD"]) - .current_dir(repo_shell.cwd.as_path()) - .output()?; - - if output.status.success() { - let output_str = String::from_utf8_lossy(&output.stdout); - if let Some(line) = output_str.lines().next() { - if let Some(symref_part) = line.split('\t').next() { - if symref_part.starts_with("ref: refs/heads/") { - let default_branch = &symref_part[16..]; // Remove "ref: refs/heads/" - let default_branch_ref = - format!("refs/remotes/{}/{}", args.remote, default_branch); - - // Create the symbolic reference - let _ = repo.reference_symbolic( - &head_ref, - &default_branch_ref, - true, - "josh remote HEAD", - ); - let _ = repo.reference_symbolic( - &format!("refs/namespaces/josh-{}/{}", args.remote, "HEAD"), - &format!("refs/heads/{}", default_branch), - true, - "josh remote HEAD", - ); - } - } - } + // Parse the output to get the default branch name + // Output format: "ref: refs/heads/main\t" + let output = std::process::Command::new("git") + .args(&["ls-remote", "--symref", &remote_url, "HEAD"]) + .current_dir(repo_path) + .output()?; + + if output.status.success() { + let output = String::from_utf8(output.stdout)?; + + if let Some((default_branch, default_branch_ref)) = try_parse_symref(&args.remote, &output) + { + repo.reference_symbolic(&head_ref, &default_branch_ref, true, "josh remote HEAD")?; + + repo.reference_symbolic( + &format!("refs/namespaces/josh-{}/{}", args.remote, "HEAD"), + &format!("refs/heads/{}", default_branch), + true, + "josh remote HEAD", + )?; } } @@ -641,69 +562,67 @@ fn handle_fetch(args: &FetchArgs) -> Result<(), Box> { let filter_args = FilterArgs { remote: args.remote.clone(), }; - handle_filter_internal(&filter_args, false)?; + + handle_filter_repo(&filter_args, repo_path, false)?; + // Note: fetch doesn't checkout, it just updates the refs + eprintln!("Fetched from remote: {}", args.remote); - println!("Fetched from remote: {}", args.remote); Ok(()) } -fn handle_push(args: &PushArgs) -> Result<(), Box> { - // Get current working directory (should be inside a git repository) - let current_dir = std::env::current_dir()?; - - // Create shell for the current repository directory - let repo_shell = Shell { - cwd: current_dir.clone(), - }; - +fn handle_push(args: &PushArgs) -> anyhow::Result<()> { // Read filter from git config for the specific remote - let repo = - git2::Repository::open_from_env().map_err(|e| format!("Not in a git repository: {}", e))?; - let config = repo - .config() - .map_err(|e| format!("Failed to get git config: {}", e))?; + let repo = git2::Repository::open_from_env().context("Not in a git repository")?; + let repo_path = repo.path().parent().unwrap(); + + let config = repo.config().context("Failed to get git config")?; // Step 2: Apply reverse filtering and push to actual remote let filter_str = config .get_string(&format!("josh-remote.{}.filter", args.remote)) - .map_err(|e| format!("Failed to read filter from git config: {}", e))?; + .context("Failed to read filter from git config")?; // Parse the filter using Josh API - let filter = - josh::filter::parse(&filter_str).map_err(|e| format!("Failed to parse filter: {}", e.0))?; + let filter = josh::filter::parse(&filter_str) + .map_err(from_josh_err) + .context("Failed to parse filter")?; + + josh::cache_sled::sled_load(repo_path) + .map_err(from_josh_err) + .context("Failed to load sled cache")?; - josh::cache_sled::sled_load(&repo_shell.cwd.as_path()).unwrap(); let cache = std::sync::Arc::new( josh::cache_stack::CacheStack::new() .with_backend(josh::cache_sled::SledCacheBackend::default()) .with_backend( - josh::cache_notes::NotesCacheBackend::new(&repo_shell.cwd.as_path()) - .map_err(|e| format!("Failed to create NotesCacheBackend: {}", e.0))?, + josh::cache_notes::NotesCacheBackend::new(repo_path) + .map_err(from_josh_err) + .context("Failed to create NotesCacheBackend")?, ), ); // Open Josh transaction let transaction = josh::cache::TransactionContext::from_env(cache.clone()) - .map_err(|e| format!("Failed TransactionContext::from_env: {}", e.0))? + .map_err(from_josh_err) + .context("Failed TransactionContext::from_env")? .open(None) - .map_err(|e| format!("Failed TransactionContext::open: {}", e.0))?; + .map_err(from_josh_err) + .context("Failed TransactionContext::open")?; // Get the remote URL from josh-remote config let remote_url = config .get_string(&format!("josh-remote.{}.url", args.remote)) - .map_err(|e| format!("Failed to get remote URL for '{}': {}", args.remote, e))?; + .with_context(|| format!("Failed to get remote URL for '{}'", args.remote))?; // If no refspecs provided, push the current branch let refspecs = if args.refspecs.is_empty() { // Get the current branch name - let head = repo - .head() - .map_err(|e| format!("Failed to get HEAD: {}", e))?; + let head = repo.head().context("Failed to get HEAD")?; let current_branch = head .shorthand() - .ok_or("Failed to get current branch name")?; + .context("Failed to get current branch name")?; vec![current_branch.to_string()] } else { @@ -728,9 +647,9 @@ fn handle_push(args: &PushArgs) -> Result<(), Box> { // Get the current commit of the local ref let local_commit = repo .resolve_reference_from_short_name(&local_ref) - .map_err(|e| format!("Failed to resolve local ref '{}': {}", local_ref, e))? + .with_context(|| format!("Failed to resolve local ref '{}'", local_ref))? .target() - .ok_or("Failed to get target of local ref")?; + .context("Failed to get target of local ref")?; // Get the original target (the base commit that was filtered) // We need to find the original commit in the unfiltered repository @@ -762,7 +681,7 @@ fn handle_push(args: &PushArgs) -> Result<(), Box> { // Check for errors for error in errors { - return Err(format!("josh filter error: {}", error.1.0).into()); + return Err(anyhow::anyhow!("josh filter error: {}", error.1.0).into()); } if let Some((_, filtered_oid)) = filtered_oids.first() { @@ -775,8 +694,8 @@ fn handle_push(args: &PushArgs) -> Result<(), Box> { git2::Oid::zero() }; - debug!("old_filtered_oid: {:?}", old_filtered_oid); - debug!("original_target: {:?}", original_target); + log::debug!("old_filtered_oid: {:?}", old_filtered_oid); + log::debug!("original_target: {:?}", original_target); // Set push mode based on the flags let push_mode = if args.split { @@ -808,14 +727,15 @@ fn handle_push(args: &PushArgs) -> Result<(), Box> { None, // reparent_orphans &mut changes, // change_ids ) - .map_err(|e| format!("Failed to unapply filter: {}", e.0))?; + .map_err(from_josh_err) + .context("Failed to unapply filter")?; // Define variables needed for build_to_push let baseref = remote_ref.clone(); let oid_to_push = unfiltered_oid; let old = original_target; - debug!("unfiltered_oid: {:?}", unfiltered_oid); + log::debug!("unfiltered_oid: {:?}", unfiltered_oid); let to_push = build_to_push( transaction.repo(), @@ -823,32 +743,30 @@ fn handle_push(args: &PushArgs) -> Result<(), Box> { push_mode, &baseref, &author, - remote_ref, + &remote_ref, oid_to_push, old, ) - .map_err(|e| format!("Failed to build to push: {}", e.0))?; - - debug!("to_push: {:?}", to_push); + .map_err(from_josh_err) + .context("Failed to build to push")?; - // Get PATH environment variable for shell commands - let path_env = std::env::var("PATH").unwrap_or_default(); + log::debug!("to_push: {:?}", to_push); // Process each entry in to_push (similar to josh-proxy) for (refname, oid, _) in to_push { // Build git push command - let mut git_push_cmd = vec!["git", "push"]; + let mut git_push_args = vec!["push"]; if args.force || push_mode == PushMode::Split { - git_push_cmd.push("--force"); + git_push_args.push("--force"); } if args.atomic { - git_push_cmd.push("--atomic"); + git_push_args.push("--atomic"); } if args.dry_run { - git_push_cmd.push("--dry-run"); + git_push_args.push("--dry-run"); } // Determine the target remote URL @@ -857,50 +775,43 @@ fn handle_push(args: &PushArgs) -> Result<(), Box> { // Create refspec: oid:refname let push_refspec = format!("{}:{}", oid, refname); - git_push_cmd.push(&target_remote); - git_push_cmd.push(&push_refspec); + git_push_args.push(&target_remote); + git_push_args.push(&push_refspec); // Use direct spawn so users can see git push progress - let exit_code = spawn_git_command( - repo_shell.cwd.as_path(), - &git_push_cmd[1..], // Skip "git" since spawn_git_command adds it - &[("PATH", &path_env)], - )?; - - if exit_code != 0 { - return Err( - format!("git push failed for {}: exit code {}", refname, exit_code).into(), - ); - } - - println!("Pushed {} to {}/{}", oid, args.remote, refname); + spawn_git_command( + repo_path, + &git_push_args, // Skip "git" since spawn_git_command adds it + &[], + ) + .context("git push failed")?; + + eprintln!("Pushed {} to {}/{}", oid, args.remote, refname); } } Ok(()) } -fn handle_remote(args: &RemoteArgs) -> Result<(), Box> { +fn handle_remote(args: &RemoteArgs) -> anyhow::Result<()> { match &args.command { RemoteCommand::Add(add_args) => handle_remote_add(add_args), } } -fn handle_remote_add(args: &RemoteAddArgs) -> Result<(), Box> { +fn handle_remote_add(args: &RemoteAddArgs) -> anyhow::Result<()> { // Check if we're in a git repository - let repo = - git2::Repository::open_from_env().map_err(|e| format!("Not in a git repository: {}", e))?; + let repo = git2::Repository::open_from_env().context("Not in a git repository")?; + let repo_path = repo.path().parent().unwrap(); + + handle_remote_add_repo(args, repo_path) +} + +fn handle_remote_add_repo(args: &RemoteAddArgs, repo_path: &std::path::Path) -> anyhow::Result<()> { + let repo = git2::Repository::open(repo_path).context("Failed to open repository")?; // Store the remote information in josh-remote config instead of adding a git remote - let remote_path = if args.url.starts_with("http") || args.url.starts_with("ssh://") { - args.url.clone() - } else { - // For local paths, make them absolute - std::fs::canonicalize(&args.url) - .map_err(|e| format!("Failed to resolve path {}: {}", args.url, e))? - .to_string_lossy() - .to_string() - }; + let remote_url = to_absolute_remote_url(&args.url)?; // Store the filter in git config per remote // Append ":prune=trivial-merge" to all filters unless --keep-trivial-merges flag is set @@ -910,14 +821,12 @@ fn handle_remote_add(args: &RemoteAddArgs) -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { - handle_filter_internal(args, true) +fn handle_filter(args: &FilterArgs) -> anyhow::Result<()> { + let repo = git2::Repository::open_from_env().context("Not in a git repository")?; + let repo_path = repo.path().parent().unwrap().to_path_buf(); + + handle_filter_repo(args, &repo_path, true) } /// Internal filter function that can be called from other handlers -fn handle_filter_internal( +fn handle_filter_repo( args: &FilterArgs, + repo_path: &std::path::Path, print_messages: bool, -) -> Result<(), Box> { - let repo = git2::Repository::open_from_env()?; - let repo_shell = Shell { - cwd: repo.path().parent().unwrap().to_path_buf(), - }; +) -> anyhow::Result<()> { + let repo = git2::Repository::open(repo_path).context("Failed to open repository")?; // Read the filter from git config for this remote - let config = repo - .config() - .map_err(|e| format!("Failed to get git config: {}", e))?; + let config = repo.config().context("Failed to get git config")?; let filter_key = format!("josh-remote.{}.filter", args.remote); let filter = config .get_string(&filter_key) - .map_err(|e| format!("No filter configured for remote '{}': {}", args.remote, e))?; + .with_context(|| format!("No filter configured for remote '{}'", args.remote))?; if print_messages { println!("Applying filter '{}' to remote '{}'", filter, args.remote); } // Apply josh filtering (this is the same as in handle_fetch but without the git fetch step) - apply_josh_filtering(&repo_shell, &filter, &args.remote)?; + apply_josh_filtering(repo_path, &filter, &args.remote)?; if print_messages { println!("Applied filter to remote: {}", args.remote); diff --git a/josh-core/src/changes.rs b/josh-core/src/changes.rs index 88ad99417..a99e5ff9b 100644 --- a/josh-core/src/changes.rs +++ b/josh-core/src/changes.rs @@ -51,19 +51,21 @@ fn split_changes( let commits: Vec = changes .iter() - .map(|(_, commit, _)| Ok(repo.find_commit(*commit)?)) - .collect::>>()?; - - let mut trees: Vec = commits - .iter() - .map(|commit| Ok(commit.tree()?)) - .collect::>>()?; - - trees.insert(0, repo.find_commit(base)?.tree()?); - - let diffs: Vec = (1..trees.len()) - .map(|i| Ok(repo.diff_tree_to_tree(Some(&trees[i - 1]), Some(&trees[i]), None)?)) - .collect::>>()?; + .map(|(_, commit, _)| repo.find_commit(*commit)) + .collect::, _>>()?; + + let mut trees = vec![repo.find_commit(base)?.tree()?]; + trees.extend( + commits + .iter() + .map(|commit| commit.tree()) + .collect::, _>>()?, + ); + + let diffs: Vec = trees + .windows(2) + .map(|window| repo.diff_tree_to_tree(Some(&window[0]), Some(&window[1]), None)) + .collect::, _>>()?; let mut moved = std::collections::HashSet::new(); let mut bases = vec![base]; @@ -105,27 +107,29 @@ pub fn changes_to_refs( change_author: &str, changes: Vec, ) -> JoshResult> { - let mut seen = vec![]; - let mut changes = changes; - changes.retain(|change| change.author == change_author); if !change_author.contains('@') { return Err(josh_error( "Push option 'author' needs to be set to a valid email address", )); }; + let changes: Vec = changes + .into_iter() + .filter(|change| change.author == change_author) + .collect(); + + let mut seen = std::collections::HashSet::new(); for change in changes.iter() { if let Some(id) = &change.id { if id.contains('@') { return Err(josh_error("Change id must not contain '@'")); } - if seen.contains(&id) { + if !seen.insert(id) { return Err(josh_error(&format!( "rejecting to push {:?} with duplicate label", change.commit ))); } - seen.push(id); } else { return Err(josh_error(&format!( "rejecting to push {:?} without id", @@ -136,22 +140,19 @@ pub fn changes_to_refs( Ok(changes .into_iter() - .filter(|change| change.id.is_some()) - .map(|change| { - ( - format!( - "refs/heads/@changes/{}/{}/{}", - baseref.replacen("refs/heads/", "", 1), - change.author, - change.id.as_ref().unwrap_or(&"".to_string()), - ), - change.commit, - change - .id - .as_ref() - .unwrap_or(&"JOSH_PUSH".to_string()) - .to_string(), - ) + .filter_map(|change| { + change.id.map(|id| { + ( + format!( + "refs/heads/@changes/{}/{}/{}", + baseref.replacen("refs/heads/", "", 1), + change.author, + id, + ), + change.commit, + id.to_string(), + ) + }) }) .collect()) } @@ -162,25 +163,26 @@ pub fn build_to_push( push_mode: PushMode, baseref: &str, author: &str, - ref_with_options: String, + ref_with_options: &str, oid_to_push: git2::Oid, - old: git2::Oid, + base_oid: git2::Oid, ) -> JoshResult> { if let Some(changes) = changes { - let mut v = vec![]; - let mut refs = changes_to_refs(baseref, author, changes)?; - v.append(&mut refs); + let mut push_refs = changes_to_refs(baseref, author, changes)?; + if push_mode == PushMode::Split { - split_changes(repo, &mut v, old)?; + split_changes(repo, &mut push_refs, base_oid)?; } + if push_mode == PushMode::Review { - v.push(( - ref_with_options.clone(), + push_refs.push(( + ref_with_options.to_string(), oid_to_push, "JOSH_PUSH".to_string(), )); } - v.push(( + + push_refs.push(( format!( "refs/heads/@heads/{}/{}", baseref.replacen("refs/heads/", "", 1), @@ -189,10 +191,11 @@ pub fn build_to_push( oid_to_push, baseref.replacen("refs/heads/", "", 1), )); - Ok(v) + + Ok(push_refs) } else { Ok(vec![( - ref_with_options, + ref_with_options.to_string(), oid_to_push, "JOSH_PUSH".to_string(), )]) diff --git a/josh-proxy/src/lib.rs b/josh-proxy/src/lib.rs index b78f120a0..b1d0a95b3 100644 --- a/josh-proxy/src/lib.rs +++ b/josh-proxy/src/lib.rs @@ -284,7 +284,7 @@ pub fn process_repo_update(repo_update: RepoUpdate) -> josh::JoshResult push_mode, &baseref, &author, - ref_with_options, + &ref_with_options, oid_to_push, old, )?;