diff --git a/README.md b/README.md index 428ae97c..6b2b40fb 100644 --- a/README.md +++ b/README.md @@ -163,10 +163,26 @@ cargo run --bin sashiko-cli -- [COMMAND] **Commands:** -- **`submit [INPUT]`**: Submit a patch or range for review. +- **`submit [INPUT] [--repo REPO]`**: Submit a patch or range for review. - `INPUT` can be a file path (mbox), a commit SHA, or a range (e.g., `HEAD~3..HEAD`). - If `INPUT` is omitted and stdin is piped, it reads an mbox from stdin. - If `INPUT` is omitted and stdin is a terminal, it defaults to the current `HEAD`. + - `--repo` (or `-r`) specifies an external git repository (URL or local path acting as a git remote). + - If `--repo` is provided, the CLI resolves references to full 40-character SHAs locally and verifies their existence in the remote before submission. + - If `--repo` is omitted, references (such as `HEAD`) are resolved on the server side using the local repository configured in the server's `Settings.toml` (`git.repository_path`). + +**Examples:** + +```bash +# Submit a range of commits (resolved on the server side) +sashiko-cli submit HEAD~3..HEAD + +# Submit a range from a remote URL (resolved on the client side) +sashiko-cli submit v6.1..v6.2 --repo https://github.com/torvalds/linux.git + +# Submit using a local path acting as a git remote (e.g. bare repo) +sashiko-cli submit main --repo /path/to/linux.git +``` - **`status`**: Show the current server status and queue statistics. - **`list [FILTER]`**: List recent patchsets. - `FILTER` can be a status (e.g., `pending`, `failed`, `reviewed`) or a search term. diff --git a/src/bin/sashiko-cli.rs b/src/bin/sashiko-cli.rs index a95fba8f..387f7d30 100644 --- a/src/bin/sashiko-cli.rs +++ b/src/bin/sashiko-cli.rs @@ -20,8 +20,9 @@ use sashiko::api::{PatchsetsResponse, SubmitRequest, SubmitResponse}; use sashiko::settings::Settings; use serde_json::Value; use std::io::{IsTerminal, Read, Write}; -use std::path::PathBuf; +use std::path::Path; use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; +use tokio::process::Command; #[derive(Parser)] #[command(name = "sashiko-cli")] @@ -59,9 +60,9 @@ enum Commands { #[arg(long, value_enum)] r#type: Option, - /// Override repository path (defaults to settings) + /// Override repository path (defaults to settings, can be a local path, URL, or remote name) #[arg(long, short = 'r')] - repo: Option, + repo: Option, /// Baseline commit (for mbox injection only) #[arg(long)] @@ -190,16 +191,20 @@ async fn handle_submit( base_url: &str, input: Option, explicit_type: Option, - repo: Option, + repo: Option, baseline: Option, skip_subjects: Option>, only_subjects: Option>, format: OutputFormat, ) -> Result<()> { + if let Some(r) = &repo { + validate_remote(r).await?; + } + let url = format!("{}/api/submit", base_url); // DWIM Detection Logic - let (submission_type, target) = if let Some(t) = explicit_type { + let (submission_type, mut target) = if let Some(t) = explicit_type { (t, input.unwrap_or_else(|| "HEAD".to_string())) } else { // Auto-detect based on input @@ -208,12 +213,12 @@ async fn handle_submit( (SubmitType::Mbox, s) } else if s.contains("..") { (SubmitType::Range, s) - } else if PathBuf::from(&s).exists() { + } else if Path::new(&s).exists() { // If it's a file, assume mbox. If it's a dir, maybe repo? // For safety, if it looks like a commit (hex), prefer Remote unless file exists. // But filenames can look like anything. // Sashiko deals with mbox files primarily. - let p = PathBuf::from(&s); + let p = Path::new(&s); if p.is_file() { (SubmitType::Mbox, s) } else { @@ -236,6 +241,42 @@ async fn handle_submit( } }; + // Resolve SHAs if --repo is provided + if let Some(r) = &repo + && (submission_type == SubmitType::Remote || submission_type == SubmitType::Range) + { + let context = if Path::new(r).is_dir() { + Some(r.as_str()) + } else { + None + }; + + if target.contains("..") { + let parts: Vec<&str> = target.split("..").collect(); + if parts.len() == 2 { + let start = resolve_ref(context, parts[0]).await?; + let end = resolve_ref(context, parts[1]).await?; + + // Check if SHAs exist in the remote (if remote is not the context) + if context.is_none() || context != Some(r) { + verify_sha_exists_in_remote(r, &start).await?; + verify_sha_exists_in_remote(r, &end).await?; + } + + target = format!("{}..{}", start, end); + } + } else { + let sha = resolve_ref(context, &target).await?; + + // Check if SHA exists in the remote (if remote is not the context) + if context.is_none() || context != Some(r) { + verify_sha_exists_in_remote(r, &sha).await?; + } + + target = sha; + } + } + let payload = match submission_type { SubmitType::Mbox => { let content = if target == "-" { @@ -255,26 +296,18 @@ async fn handle_submit( only_subjects: only_subjects.clone(), } } - SubmitType::Remote => { - let repo_path = repo.map(|p| p.to_string_lossy().to_string()); - - SubmitRequest::Remote { - sha: target, - repo: repo_path, - skip_subjects: skip_subjects.clone(), - only_subjects: only_subjects.clone(), - } - } - SubmitType::Range => { - let repo_path = repo.map(|p| p.to_string_lossy().to_string()); - - SubmitRequest::RemoteRange { - sha: target, - repo: repo_path, - skip_subjects: skip_subjects.clone(), - only_subjects: only_subjects.clone(), - } - } + SubmitType::Remote => SubmitRequest::Remote { + sha: target, + repo: repo.clone(), + skip_subjects: skip_subjects.clone(), + only_subjects: only_subjects.clone(), + }, + SubmitType::Range => SubmitRequest::RemoteRange { + sha: target, + repo: repo.clone(), + skip_subjects: skip_subjects.clone(), + only_subjects: only_subjects.clone(), + }, }; let resp = client.post(&url).json(&payload).send().await?; @@ -578,3 +611,68 @@ fn format_timestamp(ts: i64) -> String { _ => ts.to_string(), } } + +async fn validate_remote(repo: &str) -> Result<()> { + // Run git ls-remote HEAD to see if it's reachable and a valid repo + let status = Command::new("git") + .args(["ls-remote", repo, "HEAD"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .context("Failed to run git command")?; + + if !status.success() { + return Err(anyhow::anyhow!( + "Could not reach or validate git remote: {}", + repo + )); + } + Ok(()) +} + +async fn resolve_ref(context: Option<&str>, reference: &str) -> Result { + let mut cmd = Command::new("git"); + if let Some(c) = context { + cmd.arg("-C").arg(c); + } + let output = cmd + .args([ + "rev-parse", + "--verify", + &format!("{}^{{commit}}", reference), + ]) + .output() + .await + .context("Failed to run git rev-parse")?; + + if !output.status.success() { + return Err(anyhow::anyhow!( + "Failed to resolve '{}' to a commit SHA: {}", + reference, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +async fn verify_sha_exists_in_remote(repo: &str, sha: &str) -> Result<()> { + // Try git fetch --dry-run + // This is the most reliable check for a remote SHA existence + let status = Command::new("git") + .args(["fetch", "--dry-run", repo, sha]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .context("Failed to run git fetch")?; + + if !status.success() { + return Err(anyhow::anyhow!( + "Commit {} was not found in remote repository {}", + sha, + repo + )); + } + Ok(()) +}