use anyhow::{Context, Result};
use git2::{DiffOptions, Oid, Repository, StatusOptions};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
use super::git_utils::{
add_unique_file, classify_delta_status, classify_file_status, extract_file_path,
normalize_file_lists, FileChangeType,
};
#[derive(Debug, Clone)]
pub struct GitChangeTracker {
repo_path: PathBuf,
pub(crate) workflow_start_commit: Option<String>,
pub(crate) step_changes: HashMap<String, StepChanges>,
pub(crate) current_step_id: Option<String>,
pub(crate) last_commit: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StepChanges {
pub files_added: Vec<String>,
pub files_modified: Vec<String>,
pub files_deleted: Vec<String>,
pub commits: Vec<String>,
pub insertions: usize,
pub deletions: usize,
}
#[derive(Debug, Clone, Default)]
struct DiffStats {
insertions: usize,
deletions: usize,
files_added: Vec<String>,
files_modified: Vec<String>,
files_deleted: Vec<String>,
}
impl StepChanges {
pub fn files_changed(&self) -> Vec<String> {
let mut all_files = Vec::new();
all_files.extend(self.files_added.clone());
all_files.extend(self.files_modified.clone());
all_files.extend(self.files_deleted.clone());
all_files.sort();
all_files.dedup();
all_files
}
pub fn commit_count(&self) -> usize {
self.commits.len()
}
pub fn merge(&mut self, other: &StepChanges) {
self.files_added.extend(other.files_added.clone());
self.files_modified.extend(other.files_modified.clone());
self.files_deleted.extend(other.files_deleted.clone());
self.commits.extend(other.commits.clone());
self.insertions += other.insertions;
self.deletions += other.deletions;
self.files_added.sort();
self.files_added.dedup();
self.files_modified.sort();
self.files_modified.dedup();
self.files_deleted.sort();
self.files_deleted.dedup();
self.commits.sort();
self.commits.dedup();
}
pub fn filter_files(&self, files: &[String], pattern: &str) -> Vec<String> {
let matcher = match glob::Pattern::new(pattern) {
Ok(m) => m,
Err(e) => {
warn!("Invalid glob pattern '{}': {}", pattern, e);
return files.to_vec();
}
};
files
.iter()
.filter(|f| matcher.matches(f))
.cloned()
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum VariableFormat {
SpaceSeparated,
NewlineSeparated,
JsonArray,
CommaSeparated,
}
fn parse_variable_format(modifier: Option<&str>) -> VariableFormat {
match modifier {
Some("json") => VariableFormat::JsonArray,
Some("lines") | Some("newline") => VariableFormat::NewlineSeparated,
Some("csv") | Some("comma") => VariableFormat::CommaSeparated,
_ => VariableFormat::SpaceSeparated,
}
}
fn extract_glob_pattern(modifier: Option<&str>) -> Option<&str> {
modifier.filter(|m| m.contains('*') || m.contains('?'))
}
fn parse_variable_path(var_path: &str) -> Result<(Vec<&str>, Option<&str>)> {
let parts: Vec<&str> = var_path.split('.').collect();
if parts.is_empty() {
return Err(anyhow::anyhow!("Empty variable path"));
}
if let Some(pos) = parts.last().unwrap().find(':') {
let last = parts.last().unwrap();
let base = &last[..pos];
let modifier = &last[pos + 1..];
let base_path = parts[..parts.len() - 1]
.iter()
.chain(&[base])
.copied()
.collect::<Vec<_>>();
Ok((base_path, Some(modifier)))
} else {
Ok((parts, None))
}
}
impl GitChangeTracker {
pub fn new(working_dir: impl AsRef<Path>) -> Result<Self> {
let repo_path = working_dir.as_ref().to_path_buf();
if let Ok(repo) = Repository::open(&repo_path) {
let head_commit = Self::get_head_commit(&repo)?;
debug!("Initialized GitChangeTracker at commit: {:?}", head_commit);
Ok(Self {
repo_path,
workflow_start_commit: head_commit.clone(),
step_changes: HashMap::new(),
current_step_id: None,
last_commit: head_commit,
})
} else {
debug!("Working directory is not a git repository, git tracking disabled");
Ok(Self {
repo_path,
workflow_start_commit: None,
step_changes: HashMap::new(),
current_step_id: None,
last_commit: None,
})
}
}
fn get_head_commit(repo: &Repository) -> Result<Option<String>> {
if repo.head_detached()? {
let head = repo.head()?;
if let Some(oid) = head.target() {
return Ok(Some(oid.to_string()));
}
} else {
let head = repo.head()?;
if let Some(oid) = head.target() {
return Ok(Some(oid.to_string()));
}
}
Ok(None)
}
pub fn begin_step(&mut self, step_id: impl Into<String>) -> Result<()> {
let step_id = step_id.into();
debug!("Beginning step: {}", step_id);
if let Ok(repo) = Repository::open(&self.repo_path) {
self.last_commit = Self::get_head_commit(&repo)?;
}
self.current_step_id = Some(step_id.clone());
self.step_changes.insert(step_id, StepChanges::default());
Ok(())
}
pub fn complete_step(&mut self) -> Result<StepChanges> {
let step_id = self
.current_step_id
.clone()
.ok_or_else(|| anyhow::anyhow!("No active step to complete"))?;
debug!("Completing step: {}", step_id);
if self.workflow_start_commit.is_some() {
let changes = self.calculate_step_changes()?;
self.step_changes.insert(step_id.clone(), changes.clone());
if let Ok(repo) = Repository::open(&self.repo_path) {
self.last_commit = Self::get_head_commit(&repo)?;
}
self.current_step_id = None;
Ok(changes)
} else {
self.current_step_id = None;
Ok(StepChanges::default())
}
}
fn collect_uncommitted_changes(repo: &Repository) -> Result<StepChanges> {
let mut changes = StepChanges::default();
let mut status_opts = StatusOptions::new();
status_opts.include_untracked(true);
let statuses = repo.statuses(Some(&mut status_opts))?;
for entry in statuses.iter() {
let path = match entry.path() {
Some(p) => p,
None => continue,
};
match classify_file_status(entry.status()) {
FileChangeType::Added => changes.files_added.push(path.to_string()),
FileChangeType::Modified => changes.files_modified.push(path.to_string()),
FileChangeType::Deleted => changes.files_deleted.push(path.to_string()),
FileChangeType::Unknown => {}
}
}
Ok(changes)
}
fn collect_commits_between(
repo: &Repository,
from_oid: Oid,
to_oid: Oid,
) -> Result<Vec<String>> {
let mut commits = Vec::new();
let mut revwalk = repo.revwalk()?;
revwalk.push(to_oid)?;
revwalk.hide(from_oid)?;
for oid in revwalk {
commits.push(oid?.to_string());
}
Ok(commits)
}
fn has_new_commits(last: &Option<String>, current: &Option<String>) -> bool {
match (last, current) {
(Some(l), Some(c)) => l != c,
_ => false,
}
}
fn calculate_diff_stats(repo: &Repository, from_oid: Oid, to_oid: Oid) -> Result<DiffStats> {
let from_commit = repo.find_commit(from_oid)?;
let to_commit = repo.find_commit(to_oid)?;
let from_tree = from_commit.tree()?;
let to_tree = to_commit.tree()?;
let diff = repo.diff_tree_to_tree(
Some(&from_tree),
Some(&to_tree),
Some(&mut DiffOptions::new()),
)?;
let stats = diff.stats()?;
let mut diff_stats = DiffStats {
insertions: stats.insertions(),
deletions: stats.deletions(),
..Default::default()
};
diff.foreach(
&mut |delta, _progress| {
if let Some(path_str) = extract_file_path(&delta) {
match classify_delta_status(delta.status()) {
FileChangeType::Added => {
add_unique_file(&mut diff_stats.files_added, path_str)
}
FileChangeType::Modified => {
add_unique_file(&mut diff_stats.files_modified, path_str)
}
FileChangeType::Deleted => {
add_unique_file(&mut diff_stats.files_deleted, path_str)
}
FileChangeType::Unknown => {}
}
}
true
},
None,
None,
None,
)?;
Ok(diff_stats)
}
pub(crate) fn calculate_step_changes(&self) -> Result<StepChanges> {
let repo = Repository::open(&self.repo_path).context("Failed to open git repository")?;
let current_commit = Self::get_head_commit(&repo)?;
let mut changes = Self::collect_uncommitted_changes(&repo)?;
if Self::has_new_commits(&self.last_commit, ¤t_commit) {
let last_oid = Oid::from_str(self.last_commit.as_ref().unwrap())?;
let current_oid = Oid::from_str(current_commit.as_ref().unwrap())?;
changes.commits = Self::collect_commits_between(&repo, last_oid, current_oid)?;
let diff_stats = Self::calculate_diff_stats(&repo, last_oid, current_oid)?;
changes.insertions = diff_stats.insertions;
changes.deletions = diff_stats.deletions;
changes.files_added.extend(diff_stats.files_added);
changes.files_modified.extend(diff_stats.files_modified);
changes.files_deleted.extend(diff_stats.files_deleted);
}
normalize_file_lists(&mut changes);
debug!(
"Step changes: {} added, {} modified, {} deleted, {} commits",
changes.files_added.len(),
changes.files_modified.len(),
changes.files_deleted.len(),
changes.commits.len()
);
Ok(changes)
}
pub fn get_step_changes(&self, step_id: &str) -> Option<&StepChanges> {
self.step_changes.get(step_id)
}
pub fn get_workflow_changes(&self) -> StepChanges {
let mut cumulative = StepChanges::default();
for changes in self.step_changes.values() {
cumulative.merge(changes);
}
cumulative
}
pub fn format_file_list(files: &[String], format: VariableFormat) -> String {
match format {
VariableFormat::SpaceSeparated => files.join(" "),
VariableFormat::NewlineSeparated => files.join("\n"),
VariableFormat::JsonArray => {
serde_json::to_string(files).unwrap_or_else(|_| "[]".to_string())
}
VariableFormat::CommaSeparated => files.join(","),
}
}
pub fn resolve_variable(&self, var_path: &str) -> Result<String> {
let (base_path, modifier) = parse_variable_path(var_path)?;
let format = parse_variable_format(modifier);
let pattern = extract_glob_pattern(modifier);
match base_path[..] {
["step", var_name] => {
let changes = self.get_current_step_changes();
self.resolve_step_variable(&changes, var_name, format, pattern)
}
["workflow", var_name] => {
let changes = self.get_workflow_changes();
self.resolve_step_variable(&changes, var_name, format, pattern)
}
_ => Err(anyhow::anyhow!("Unknown git variable path: {}", var_path)),
}
}
fn get_current_step_changes(&self) -> StepChanges {
if let Some(step_id) = &self.current_step_id {
self.step_changes.get(step_id).cloned().unwrap_or_default()
} else {
StepChanges::default()
}
}
fn resolve_step_variable(
&self,
changes: &StepChanges,
var_name: &str,
format: VariableFormat,
pattern: Option<&str>,
) -> Result<String> {
match var_name {
"files_added" => {
Ok(self.resolve_file_list(&changes.files_added, changes, format, pattern))
}
"files_modified" => {
Ok(self.resolve_file_list(&changes.files_modified, changes, format, pattern))
}
"files_deleted" => {
Ok(self.resolve_file_list(&changes.files_deleted, changes, format, pattern))
}
"files_changed" => {
let all = changes.files_changed();
Ok(self.resolve_file_list(&all, changes, format, pattern))
}
"commits" => Ok(Self::format_file_list(&changes.commits, format)),
"commit_count" => Ok(changes.commit_count().to_string()),
"insertions" => Ok(changes.insertions.to_string()),
"deletions" => Ok(changes.deletions.to_string()),
_ => Err(anyhow::anyhow!("Unknown step variable: {}", var_name)),
}
}
fn resolve_file_list(
&self,
files: &[String],
changes: &StepChanges,
format: VariableFormat,
pattern: Option<&str>,
) -> String {
let filtered = if let Some(p) = pattern {
changes.filter_files(files, p)
} else {
files.to_vec()
};
Self::format_file_list(&filtered, format)
}
pub fn is_active(&self) -> bool {
self.workflow_start_commit.is_some()
}
}