securegit 0.8.5

Zero-trust git replacement with 12 built-in security scanners, LLM redteam bridge, universal undo, durable backups, and a 50-tool MCP server
Documentation
use crate::cli::UI;
use crate::ops::refstore;
use anyhow::{bail, Result};
use git2::Repository;
use serde::{Deserialize, Serialize};
use std::path::Path;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictRecord {
    pub trigger_commit: String,
    pub operation: String,
    pub source_ref: String,
    pub timestamp: String,
    pub files: Vec<ConflictFileEntry>,
    pub resolved: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictFileEntry {
    pub path: String,
    pub base_oid: Option<String>,
    pub ours_oid: Option<String>,
    pub theirs_oid: Option<String>,
    pub resolved: bool,
}

/// Record conflict metadata for all conflicted files in the index.
pub fn record_conflicts(
    path: &Path,
    trigger_commit: &str,
    operation: &str,
    source_ref: &str,
) -> Result<()> {
    let repo = crate::ops::open_repo(path)?;
    let files = extract_index_conflicts(&repo)?;

    if files.is_empty() {
        return Ok(());
    }

    let record = ConflictRecord {
        trigger_commit: trigger_commit.to_string(),
        operation: operation.to_string(),
        source_ref: source_ref.to_string(),
        timestamp: crate::ops::oplog::now_iso8601_pub(),
        files,
        resolved: false,
    };

    let ref_name = format!("refs/conflicts/{}", trigger_commit);
    refstore::write_json_ref(&repo, &ref_name, &record)?;

    Ok(())
}

/// List all active (unresolved) conflict records.
pub fn list_conflicts(path: &Path, _verbose: bool) -> Result<Vec<ConflictRecord>> {
    let repo = crate::ops::open_repo(path)?;
    let refs = refstore::list_refs_with_prefix(&repo, "refs/conflicts/")?;

    let mut records = Vec::new();
    for ref_name in &refs {
        if let Some(record) = refstore::read_json_ref::<ConflictRecord>(&repo, ref_name)? {
            if !record.resolved {
                records.push(record);
            }
        }
    }

    Ok(records)
}

/// Display conflicts.
pub fn execute_list(path: &Path, verbose: bool, ui: &UI) -> Result<()> {
    let records = list_conflicts(path, verbose)?;

    if records.is_empty() {
        ui.info("No active conflicts");
        return Ok(());
    }

    for record in &records {
        ui.section(&format!(
            "{} conflict from {} of '{}'",
            record.operation, record.timestamp, record.source_ref
        ));

        for file in &record.files {
            if file.resolved {
                ui.status_item(true, format!("resolved  {}", file.path));
            } else {
                ui.status_item(false, format!("CONFLICT  {}", file.path));
            }

            if verbose && !file.resolved {
                if let Some(ref base) = file.base_oid {
                    ui.field("base", &base[..7.min(base.len())]);
                }
                if let Some(ref ours) = file.ours_oid {
                    ui.field("ours", &ours[..7.min(ours.len())]);
                }
                if let Some(ref theirs) = file.theirs_oid {
                    ui.field("theirs", &theirs[..7.min(theirs.len())]);
                }
            }
        }
    }

    Ok(())
}

/// Mark a file as resolved. Optionally accept "ours" or "theirs".
pub fn resolve_file(path: &Path, file: &str, accept: Option<&str>, ui: &UI) -> Result<()> {
    let repo = crate::ops::open_repo(path)?;

    // If accept is specified, checkout that version
    if let Some(strategy) = accept {
        let index = repo.index()?;
        let conflicts: Vec<_> = index.conflicts()?.collect();
        let mut resolved_any = false;

        for conflict_result in conflicts {
            let conflict = conflict_result?;
            let conflict_path = conflict
                .our
                .as_ref()
                .or(conflict.their.as_ref())
                .and_then(|e| std::str::from_utf8(&e.path).ok())
                .unwrap_or("");

            if conflict_path == file {
                let entry = match strategy {
                    "ours" => conflict
                        .our
                        .ok_or_else(|| anyhow::anyhow!("No 'ours' version for '{}'", file))?,
                    "theirs" => conflict
                        .their
                        .ok_or_else(|| anyhow::anyhow!("No 'theirs' version for '{}'", file))?,
                    _ => bail!("Unknown strategy '{}'. Use 'ours' or 'theirs'.", strategy),
                };

                // Write the chosen blob to the working tree
                let blob = repo.find_blob(entry.id)?;
                let full_path = path.join(file);
                std::fs::write(&full_path, blob.content())?;

                // Stage the resolved version
                drop(index);
                let mut index = repo.index()?;
                index.add_path(Path::new(file))?;
                index.write()?;

                resolved_any = true;
                break;
            }
        }

        if !resolved_any {
            bail!("File '{}' is not in a conflicted state.", file);
        }
    } else {
        // Manual resolution: just stage what's in the working tree
        let mut index = repo.index()?;
        index.add_path(Path::new(file))?;
        index.write()?;
    }

    // Update conflict record
    update_conflict_record(&repo, file)?;

    ui.success(format!("Resolved: {}", file));
    Ok(())
}

/// Clean up all resolved conflict refs.
pub fn cleanup_resolved(path: &Path) -> Result<()> {
    let repo = crate::ops::open_repo(path)?;
    let refs = refstore::list_refs_with_prefix(&repo, "refs/conflicts/")?;

    for ref_name in &refs {
        refstore::delete_ref(&repo, ref_name)?;
    }

    Ok(())
}

fn extract_index_conflicts(repo: &Repository) -> Result<Vec<ConflictFileEntry>> {
    let index = repo.index()?;
    let mut files = Vec::new();

    for conflict_result in index.conflicts()? {
        let conflict = conflict_result?;

        let path_str = conflict
            .our
            .as_ref()
            .or(conflict.their.as_ref())
            .or(conflict.ancestor.as_ref())
            .and_then(|e| std::str::from_utf8(&e.path).ok())
            .unwrap_or("")
            .to_string();

        files.push(ConflictFileEntry {
            path: path_str,
            base_oid: conflict.ancestor.as_ref().map(|e| e.id.to_string()),
            ours_oid: conflict.our.as_ref().map(|e| e.id.to_string()),
            theirs_oid: conflict.their.as_ref().map(|e| e.id.to_string()),
            resolved: false,
        });
    }

    Ok(files)
}

fn update_conflict_record(repo: &Repository, resolved_file: &str) -> Result<()> {
    let refs = refstore::list_refs_with_prefix(repo, "refs/conflicts/")?;

    for ref_name in &refs {
        if let Some(mut record) = refstore::read_json_ref::<ConflictRecord>(repo, ref_name)? {
            let mut changed = false;
            for file in &mut record.files {
                if file.path == resolved_file && !file.resolved {
                    file.resolved = true;
                    changed = true;
                }
            }

            if changed {
                record.resolved = record.files.iter().all(|f| f.resolved);
                refstore::write_json_ref(repo, ref_name, &record)?;
            }
        }
    }

    Ok(())
}