use anyhow::{Context, Result};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
const WORKTREE_DIR_NAME: &str = "semver-worktrees";
pub fn read_git_file(repo: &Path, git_ref: &str, file_path: &str) -> Option<String> {
let output = Command::new("git")
.args(["show", &format!("{git_ref}:{file_path}")])
.current_dir(repo)
.output()
.map_err(|e| {
tracing::trace!(
%e,
repo = %repo.display(),
%git_ref,
%file_path,
"git show failed to execute"
);
e
})
.ok()?;
if !output.status.success() {
tracing::trace!(
repo = %repo.display(),
%git_ref,
%file_path,
stderr = %String::from_utf8_lossy(&output.stderr).trim(),
"git show returned non-zero"
);
return None;
}
String::from_utf8(output.stdout)
.map_err(|e| {
tracing::trace!(
%e,
%file_path,
"git show output was not valid UTF-8"
);
e
})
.ok()
}
pub fn git_diff_file(repo: &Path, from_ref: &str, to_ref: &str, file_path: &str) -> Option<String> {
let output = Command::new("git")
.args([
"-C",
&repo.to_string_lossy(),
"diff",
&format!("{from_ref}..{to_ref}"),
"--",
file_path,
])
.output()
.map_err(|e| {
tracing::trace!(
%e,
repo = %repo.display(),
%from_ref,
%to_ref,
%file_path,
"git diff failed to execute"
);
e
})
.ok()?;
if !output.status.success() {
tracing::trace!(
repo = %repo.display(),
%from_ref,
%to_ref,
%file_path,
stderr = %String::from_utf8_lossy(&output.stderr).trim(),
"git diff returned non-zero"
);
return None;
}
let content = String::from_utf8_lossy(&output.stdout).to_string();
if content.is_empty() {
None
} else {
Some(content)
}
}
#[derive(Debug, Clone)]
pub struct DeprecationCommit {
pub sha: String,
pub component: String,
}
pub fn find_deprecation_commits(
repo: &Path,
from_ref: &str,
to_ref: &str,
) -> Vec<DeprecationCommit> {
let output = Command::new("git")
.args([
"log",
"--diff-filter=A",
"--name-only",
"--pretty=format:%h",
&format!("{}..{}", from_ref, to_ref),
"--",
"*/deprecated/components/*/[A-Z]*.tsx",
"*/deprecated/components/*/[A-Z]*.ts",
])
.current_dir(repo)
.output();
let output = match output {
Ok(o) if o.status.success() => o,
Ok(o) => {
tracing::debug!(
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
"git log for deprecation commits returned non-zero"
);
return vec![];
}
Err(e) => {
tracing::debug!(%e, "Failed to run git log for deprecation commits");
return vec![];
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut result = Vec::new();
let mut current_sha = String::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if !line.contains('/') {
current_sha = line.to_string();
continue;
}
if current_sha.is_empty() {
continue;
}
if let Some(component) = extract_component_from_deprecated_path(line) {
if !result
.iter()
.any(|dc: &DeprecationCommit| dc.sha == current_sha && dc.component == component)
{
result.push(DeprecationCommit {
sha: current_sha.clone(),
component,
});
}
}
}
result
}
fn extract_component_from_deprecated_path(path: &str) -> Option<String> {
let parts: Vec<&str> = path.split('/').collect();
for (i, part) in parts.iter().enumerate() {
if *part == "deprecated" && i + 2 < parts.len() && parts[i + 1] == "components" {
return Some(parts[i + 2].to_string());
}
}
None
}
pub fn commit_co_changed_families(
repo: &Path,
commit_sha: &str,
deprecated_family: &str,
) -> Vec<String> {
let output = Command::new("git")
.args([
"show",
"--name-only",
"--diff-filter=AM",
"--pretty=format:",
commit_sha,
])
.current_dir(repo)
.output();
let output = match output {
Ok(o) if o.status.success() => o,
Ok(o) => {
tracing::debug!(
sha = commit_sha,
stderr = %String::from_utf8_lossy(&o.stderr).trim(),
"git show for commit co-change returned non-zero"
);
return vec![];
}
Err(e) => {
tracing::debug!(%e, sha = commit_sha, "Failed to run git show for co-change");
return vec![];
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut families = std::collections::HashSet::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if !line.contains("/components/") || line.contains("/deprecated/") {
continue;
}
if !line.ends_with(".tsx") && !line.ends_with(".ts") {
continue;
}
if line.contains("/examples/")
|| line.contains("/__tests__/")
|| line.contains("__snapshots__")
|| line.ends_with(".test.tsx")
|| line.ends_with(".test.ts")
|| line.ends_with(".spec.tsx")
|| line.ends_with(".spec.ts")
|| line.ends_with(".css")
|| line.ends_with(".md")
|| line.ends_with(".snap")
{
continue;
}
let filename = line.rsplit('/').next().unwrap_or("");
if filename == "index.ts" || filename == "index.tsx" {
continue;
}
if let Some(family) = extract_family_from_components_path(line) {
if family != deprecated_family {
families.insert(family);
}
}
}
families.into_iter().collect()
}
fn extract_family_from_components_path(path: &str) -> Option<String> {
let parts: Vec<&str> = path.split('/').collect();
for (i, part) in parts.iter().enumerate() {
if *part == "components" && i + 1 < parts.len() {
if i > 0 && parts[i - 1] == "deprecated" {
continue;
}
return Some(parts[i + 1].to_string());
}
}
None
}
pub fn sanitize_ref_name(git_ref: &str) -> String {
let sanitized: String = git_ref
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_ascii_control() => '_',
c => c,
})
.collect();
if sanitized.len() > 100 {
sanitized[..100].to_string()
} else {
sanitized
}
}
fn repo_hash(repo: &Path) -> String {
let mut hasher = DefaultHasher::new();
repo.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn worktree_path_for(repo: &Path, git_ref: &str) -> PathBuf {
let sanitized = sanitize_ref_name(git_ref);
std::env::temp_dir()
.join(WORKTREE_DIR_NAME)
.join(repo_hash(repo))
.join(sanitized)
}
pub fn worktree_dir_for(repo: &Path) -> PathBuf {
std::env::temp_dir()
.join(WORKTREE_DIR_NAME)
.join(repo_hash(repo))
}
pub struct WorktreeGuard {
repo_root: PathBuf,
worktree_path: PathBuf,
git_ref: String,
created: bool,
}
impl WorktreeGuard {
pub fn new(repo: &Path, git_ref: &str) -> Result<Self> {
let repo = repo
.canonicalize()
.with_context(|| format!("Failed to canonicalize repo path: {}", repo.display()))?;
let repo = repo.as_path();
validate_git_repo(repo)?;
validate_git_ref(repo, git_ref)?;
let worktree_path = worktree_path_for(repo, git_ref);
let mut guard = Self {
repo_root: repo.to_path_buf(),
worktree_path,
git_ref: git_ref.to_string(),
created: false,
};
if let Some(parent) = guard.worktree_path.parent() {
std::fs::create_dir_all(parent)
.context("Failed to create worktree parent directory")?;
}
if guard.worktree_path.exists() {
let _ = remove_worktree(repo, &guard.worktree_path);
let _ = std::fs::remove_dir_all(&guard.worktree_path);
}
create_worktree(repo, git_ref, &guard.worktree_path)?;
guard.created = true;
Ok(guard)
}
pub fn path(&self) -> &Path {
&self.worktree_path
}
pub fn git_ref(&self) -> &str {
&self.git_ref
}
pub fn cleanup_stale(repo: &Path) -> Result<usize> {
let repo = repo
.canonicalize()
.with_context(|| format!("Failed to canonicalize repo path: {}", repo.display()))?;
let repo = repo.as_path();
let worktree_dir = worktree_dir_for(repo);
if !worktree_dir.exists() {
return Ok(0);
}
let mut cleaned = 0;
let entries =
std::fs::read_dir(&worktree_dir).context("Failed to read worktree directory")?;
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let path = entry.path();
tracing::info!(path = %path.display(), "Cleaning up stale worktree");
if remove_worktree(repo, &path).is_ok() {
cleaned += 1;
} else {
let _ = std::fs::remove_dir_all(&path);
cleaned += 1;
}
}
}
if std::fs::read_dir(&worktree_dir)
.map(|mut d| d.next().is_none())
.unwrap_or(true)
{
let _ = std::fs::remove_dir(&worktree_dir);
}
Ok(cleaned)
}
}
impl Drop for WorktreeGuard {
fn drop(&mut self) {
if self.created {
if let Err(e) = remove_worktree(&self.repo_root, &self.worktree_path) {
tracing::warn!(
path = %self.worktree_path.display(),
error = %e,
"Failed to remove worktree"
);
let _ = std::fs::remove_dir_all(&self.worktree_path);
}
}
}
}
fn validate_git_repo(repo: &Path) -> Result<()> {
let output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(repo)
.output()
.context("Failed to run git")?;
if output.status.success() {
Ok(())
} else {
anyhow::bail!("Not a git repository: {}", repo.display())
}
}
fn validate_git_ref(repo: &Path, git_ref: &str) -> Result<()> {
let output = Command::new("git")
.args(["rev-parse", "--verify", git_ref])
.current_dir(repo)
.output()
.context("Failed to validate git ref")?;
if output.status.success() {
Ok(())
} else {
anyhow::bail!("Git ref '{}' not found", git_ref)
}
}
fn create_worktree(repo: &Path, git_ref: &str, worktree_path: &Path) -> Result<()> {
let output = Command::new("git")
.args([
"worktree",
"add",
"--detach",
&worktree_path.to_string_lossy(),
git_ref,
])
.current_dir(repo)
.output()
.context("Failed to run git worktree add")?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"git worktree add failed at {}: {}",
worktree_path.display(),
stderr.trim()
)
}
}
fn remove_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
let output = Command::new("git")
.args([
"worktree",
"remove",
"--force",
&worktree_path.to_string_lossy(),
])
.current_dir(repo)
.output()
.context("Failed to run git worktree remove")?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"git worktree remove failed at {}: {}",
worktree_path.display(),
stderr.trim()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_simple_ref() {
assert_eq!(sanitize_ref_name("v1.0.0"), "v1.0.0");
}
#[test]
fn sanitize_ref_with_slashes() {
assert_eq!(sanitize_ref_name("feature/my-branch"), "feature_my-branch");
}
#[test]
fn sanitize_ref_with_special_chars() {
assert_eq!(
sanitize_ref_name("ref:with*special?chars"),
"ref_with_special_chars"
);
}
#[test]
fn sanitize_long_ref_truncated() {
let long_ref = "a".repeat(150);
let result = sanitize_ref_name(&long_ref);
assert_eq!(result.len(), 100);
}
#[test]
fn worktree_path_in_tmp_dir() {
let repo = Path::new("/repos/my-project");
let path = worktree_path_for(repo, "v1.0.0");
let expected = std::env::temp_dir()
.join("semver-worktrees")
.join(repo_hash(repo))
.join("v1.0.0");
assert_eq!(path, expected);
}
#[test]
fn worktree_path_sanitizes_ref() {
let repo = Path::new("/repos/my-project");
let path = worktree_path_for(repo, "feature/branch");
assert!(path.ends_with("feature_branch"));
assert!(!path.starts_with(repo));
}
#[test]
fn worktree_path_deterministic_per_repo() {
let repo = Path::new("/repos/my-project");
let path1 = worktree_path_for(repo, "v1.0.0");
let path2 = worktree_path_for(repo, "v1.0.0");
assert_eq!(path1, path2);
}
#[test]
fn worktree_path_different_repos_differ() {
let repo_a = Path::new("/repos/project-a");
let repo_b = Path::new("/repos/project-b");
let path_a = worktree_path_for(repo_a, "v1.0.0");
let path_b = worktree_path_for(repo_b, "v1.0.0");
assert_ne!(path_a, path_b);
}
#[test]
fn extract_component_from_deprecated_path_standard() {
assert_eq!(
extract_component_from_deprecated_path(
"packages/react-core/src/deprecated/components/Tile/Tile.tsx"
),
Some("Tile".to_string())
);
}
#[test]
fn extract_component_from_deprecated_path_nested() {
assert_eq!(
extract_component_from_deprecated_path(
"packages/react-core/src/deprecated/components/Modal/ModalBox.tsx"
),
Some("Modal".to_string())
);
}
#[test]
fn extract_component_from_deprecated_path_non_deprecated() {
assert_eq!(
extract_component_from_deprecated_path(
"packages/react-core/src/components/Card/Card.tsx"
),
None
);
}
#[test]
fn extract_family_from_components_path_standard() {
assert_eq!(
extract_family_from_components_path(
"packages/react-core/src/components/Card/CardHeader.tsx"
),
Some("Card".to_string())
);
}
#[test]
fn extract_family_from_components_path_excludes_deprecated() {
assert_eq!(
extract_family_from_components_path(
"packages/react-core/src/deprecated/components/Tile/Tile.tsx"
),
None
);
}
#[test]
fn extract_family_from_components_path_no_match() {
assert_eq!(
extract_family_from_components_path("packages/react-core/src/helpers/util.ts"),
None
);
}
}