use crate::declare_detector;
use crate::diagnostic::{Action, Deb822Action, DebcargoAction, Diagnostic, ParagraphSelector};
use crate::{FixerError, FixerPreferences, LintianIssue, Visibility};
use debian_control::vcs::ParsedVcs;
use debian_workspace::Workspace;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use std::path::PathBuf;
#[cfg(feature = "udd")]
async fn get_branch_from_url(vcs_type: &str, url: &str) -> Result<Option<String>, FixerError> {
use sqlx::Row;
let client = debian_analyzer::udd::connect_udd_mirror()
.await
.map_err(|e| FixerError::Other(format!("Failed to connect to UDD: {}", e)))?;
let query = "SELECT branch, status, error FROM vcswatch WHERE url = $1 AND vcs = $2";
let row = sqlx::query(query)
.bind(url)
.bind(vcs_type)
.fetch_optional(&client)
.await
.map_err(|e| FixerError::Other(format!("Failed to query vcswatch: {}", e)))?;
if let Some(row) = row {
let status: String = row.get(1);
if status == "ERROR" {
let error: String = row.get(2);
return Err(FixerError::Other(format!(
"vcswatch URL unusable: {}",
error
)));
}
let branch: Option<String> = row.get(0);
Ok(branch)
} else {
Ok(None)
}
}
#[cfg(not(feature = "udd"))]
async fn get_branch_from_url(_vcs_type: &str, _url: &str) -> Result<Option<String>, FixerError> {
Err(FixerError::NoChanges)
}
fn get_default_branch(url: &str, branch: Option<&str>) -> Result<Option<String>, FixerError> {
Python::attach(|py| {
let dulwich_client = py
.import("dulwich.client")
.map_err(|e| FixerError::Other(format!("Failed to import dulwich.client: {}", e)))?;
let (client, path): (Py<PyAny>, Py<PyAny>) = dulwich_client
.call_method1("get_transport_and_path", (url,))
.map_err(|e| {
FixerError::Other(format!("Failed to call get_transport_and_path: {}", e))
})?
.extract()
.map_err(|e| FixerError::Other(format!("Failed to extract result: {}", e)))?;
let result = client
.call_method1(py, "get_refs", (path,))
.map_err(|e| FixerError::Other(format!("Failed to call get_refs: {}", e)))?;
let symrefs_obj = result
.getattr(py, "symrefs")
.map_err(|e| FixerError::Other(format!("Failed to get symrefs: {}", e)))?;
let symrefs = symrefs_obj
.bind(py)
.cast::<PyDict>()
.map_err(|e| FixerError::Other(format!("symrefs is not a dict: {}", e)))?;
let ref_name: Vec<u8> = if let Some(b) = branch {
format!("refs/heads/{}", b).into_bytes()
} else {
b"HEAD".to_vec()
};
let head = match symrefs.get_item(&ref_name) {
Ok(Some(head)) => head,
Ok(None) => return Ok(None),
Err(_) => return Ok(None),
};
let head_bytes: Vec<u8> = head
.extract()
.map_err(|e| FixerError::Other(format!("Failed to extract head: {}", e)))?;
let prefix = b"refs/heads/";
if head_bytes.starts_with(prefix) {
let branch_name = String::from_utf8(head_bytes[prefix.len()..].to_vec())
.map_err(|e| FixerError::Other(format!("Invalid UTF-8 in branch name: {}", e)))?;
Ok(Some(branch_name))
} else {
let branch_name = String::from_utf8(head_bytes)
.map_err(|e| FixerError::Other(format!("Invalid UTF-8 in branch name: {}", e)))?;
Ok(Some(branch_name))
}
})
}
pub async fn detect_async(
ws: &dyn Workspace,
preferences: &FixerPreferences,
) -> Result<Vec<Diagnostic>, FixerError> {
if preferences
.minimum_certainty
.is_some_and(|c| c < debian_analyzer::Certainty::Certain)
{
return Ok(Vec::new());
}
let debcargo_rel = PathBuf::from("debian/debcargo.toml");
let control_rel = PathBuf::from("debian/control");
let debcargo_doc = ws.parsed_debcargo()?;
let is_debcargo = debcargo_doc.is_some();
let vcs_git: Option<String> = if let Some(ref doc) = debcargo_doc {
doc.get("source")
.and_then(|s| s.as_table())
.and_then(|s| s.get("vcs_git"))
.and_then(|v| v.as_str())
.map(str::to_string)
} else {
let control = match ws.parsed_control() {
Ok(c) => c,
Err(_) => return Ok(Vec::new()),
};
control.source().and_then(|s| s.as_deb822().get("Vcs-Git"))
};
let Some(vcs_git) = vcs_git else {
return Ok(Vec::new());
};
let parsed: ParsedVcs = vcs_git
.parse()
.map_err(|e| FixerError::Other(format!("Failed to parse Vcs-Git URL: {}", e)))?;
let repo_url = &parsed.repo_url;
let branch = parsed.branch.as_deref();
let subpath = parsed.subpath.as_deref();
let new_branch = match get_branch_from_url("Git", &vcs_git).await {
Ok(Some(b)) => b,
Ok(None) => return Ok(Vec::new()),
Err(FixerError::NoChanges) => return Ok(Vec::new()),
Err(FixerError::Other(msg)) if msg.starts_with("vcswatch URL unusable") => {
tracing::debug!("{}", msg);
return Ok(Vec::new());
}
Err(e) => return Err(e),
};
if Some(new_branch.as_str()) == branch {
return Ok(Vec::new());
}
let default_branch = match get_default_branch(repo_url, branch) {
Ok(db) => db,
Err(e) => {
tracing::debug!("Failed to get default branch from {}: {}", repo_url, e);
None
}
};
let should_change = preferences.opinionated.unwrap_or(false)
|| default_branch
.as_ref()
.is_none_or(|db| new_branch.as_str() != db && Some(db.as_str()) != branch);
if !should_change {
return Ok(Vec::new());
}
let new_vcs = ParsedVcs {
repo_url: repo_url.clone(),
branch: Some(new_branch.clone()),
subpath: subpath.map(String::from),
};
let new_vcs_git = new_vcs.to_string();
let new_vcs_browser =
debian_analyzer::vcs::determine_browser_url("git", &new_vcs_git, preferences.net_access)
.map(|u| u.to_string());
let issue = LintianIssue::source("vcs-field-invalid-branch", Visibility::Warning);
let mut actions: Vec<Action> = Vec::new();
if is_debcargo {
actions.push(Action::Debcargo(DebcargoAction::SetSourceField {
file: debcargo_rel.clone(),
field: "vcs_git".into(),
value: new_vcs_git,
}));
if let Some(browser) = new_vcs_browser {
actions.push(Action::Debcargo(DebcargoAction::SetSourceField {
file: debcargo_rel,
field: "vcs_browser".into(),
value: browser,
}));
}
} else {
actions.push(Action::Deb822(Deb822Action::SetField {
file: control_rel.clone(),
paragraph: ParagraphSelector::Source,
field: "Vcs-Git".into(),
value: new_vcs_git,
}));
if let Some(browser) = new_vcs_browser {
actions.push(Action::Deb822(Deb822Action::SetField {
file: control_rel,
paragraph: ParagraphSelector::Source,
field: "Vcs-Browser".into(),
value: browser,
}));
}
}
Ok(vec![Diagnostic::with_actions(
issue,
"Vcs-Git URL points at an invalid branch.".to_string(),
"Set branch from vcswatch in Vcs-Git URL.".to_string(),
actions,
)])
}
fn detect(
ws: &dyn Workspace,
preferences: &FixerPreferences,
) -> Result<Vec<Diagnostic>, FixerError> {
let rt = tokio::runtime::Runtime::new()
.map_err(|e| FixerError::Other(format!("Failed to create runtime: {}", e)))?;
rt.block_on(detect_async(ws, preferences))
}
declare_detector! {
name: "vcs-field-invalid-branch",
tags: ["vcs-field-invalid-branch"],
triggers: [
debian_workspace::Trigger::DebcargoField("source.vcs_git"),
debian_workspace::Trigger::Deb822Field {
file: "debian/control",
paragraph_key: "Source",
field: "Vcs-Git",
},
],
cost: crate::detector::DetectorCost::Network,
detect: |ws, prefs| detect(ws, prefs),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Version;
use debian_workspace::fs_workspace::FsWorkspace;
use std::fs;
use std::path::Path;
use std::str::FromStr;
use tempfile::TempDir;
fn make_ws(base: &Path) -> FsWorkspace {
let v = Version::from_str("1.0-1").unwrap();
FsWorkspace::new(base.to_path_buf(), Some("test".into()), Some(v))
}
#[test]
fn test_no_vcs_git() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let debian_dir = base_path.join("debian");
fs::create_dir(&debian_dir).unwrap();
let control_path = debian_dir.join("control");
let control_content = r#"Source: test-package
Package: test-package
Description: Test package
This is a test package.
"#;
fs::write(&control_path, control_content).unwrap();
let preferences = FixerPreferences::default();
let rt = tokio::runtime::Runtime::new().unwrap();
let ws = make_ws(base_path);
let result = rt.block_on(detect_async(&ws, &preferences)).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_vcs_git_with_branch_parsing() {
let url = "https://salsa.debian.org/team/package.git -b debian/unstable [subdir]";
let parsed: ParsedVcs = url.parse().unwrap();
assert_eq!(parsed.repo_url, "https://salsa.debian.org/team/package.git");
assert_eq!(parsed.branch.as_deref(), Some("debian/unstable"));
assert_eq!(parsed.subpath.as_deref(), Some("subdir"));
}
#[test]
fn test_vcs_git_without_branch_parsing() {
let url = "https://salsa.debian.org/team/package.git";
let parsed: ParsedVcs = url.parse().unwrap();
assert_eq!(parsed.repo_url, "https://salsa.debian.org/team/package.git");
assert_eq!(parsed.branch, None);
assert_eq!(parsed.subpath, None);
}
#[test]
fn test_get_default_branch_with_local_repo() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().join("test-repo");
fs::create_dir(&repo_path).unwrap();
let output = std::process::Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(&repo_path)
.output();
if output.is_err() {
return;
}
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_path)
.output()
.unwrap();
fs::write(repo_path.join("test.txt"), "test").unwrap();
std::process::Command::new("git")
.args(["add", "test.txt"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&repo_path)
.output()
.unwrap();
let repo_url = format!("file://{}", repo_path.display());
let result = get_default_branch(&repo_url, None);
match result {
Ok(Some(branch)) => {
assert_eq!(branch, "main");
}
Ok(None) => {
tracing::debug!("get_default_branch returned None");
}
Err(e) => {
tracing::debug!(
"get_default_branch failed (expected if dulwich unavailable): {}",
e
);
}
}
}
#[test]
fn test_low_certainty_returns_no_changes() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let debian_dir = base_path.join("debian");
fs::create_dir(&debian_dir).unwrap();
let control_path = debian_dir.join("control");
let control_content = r#"Source: test-package
Vcs-Git: https://salsa.debian.org/team/package.git -b old-branch
Package: test-package
Description: Test package
This is a test package.
"#;
fs::write(&control_path, control_content).unwrap();
let mut preferences = FixerPreferences::default();
preferences.minimum_certainty = Some(debian_analyzer::Certainty::Possible);
let rt = tokio::runtime::Runtime::new().unwrap();
let ws = make_ws(base_path);
let result = rt.block_on(detect_async(&ws, &preferences)).unwrap();
assert!(result.is_empty());
}
#[test]
#[cfg(not(feature = "udd"))]
fn test_without_udd_feature() {
let temp_dir = TempDir::new().unwrap();
let base_path = temp_dir.path();
let debian_dir = base_path.join("debian");
fs::create_dir(&debian_dir).unwrap();
let control_path = debian_dir.join("control");
let control_content = r#"Source: test-package
Vcs-Git: https://salsa.debian.org/team/package.git
Package: test-package
Description: Test package
This is a test package.
"#;
fs::write(&control_path, control_content).unwrap();
let preferences = FixerPreferences::default();
let rt = tokio::runtime::Runtime::new().unwrap();
let ws = make_ws(base_path);
let result = rt.block_on(detect_async(&ws, &preferences)).unwrap();
assert!(result.is_empty());
}
}