a73x

src/patch.rs

Ref:   Size: 20.7 KiB

use git2::{DiffFormat, Oid, Repository};

use crate::cli::{self, SortMode};
use crate::dag;
use crate::error::Error;
use crate::event::{Action, Event, ReviewVerdict};
use crate::identity::get_author;
use crate::signing;
use crate::state::{self, PatchState, PatchStatus, Revision};

/// Auto-detect whether the branch tip has changed since the last recorded revision.
/// If it has, append a PatchRevision event and return the new `Revision` so callers
/// can update their in-memory `PatchState` without re-walking the DAG.
fn auto_detect_revision(
    repo: &Repository,
    ref_name: &str,
    patch: &PatchState,
    sk: &ed25519_dalek::SigningKey,
) -> Result<Option<Revision>, Error> {
    let current_rev = patch.revisions.last().map(|r| r.number).unwrap_or(0);

    // Try to resolve the branch tip
    let tip_oid = match patch.resolve_head(repo) {
        Ok(oid) => oid,
        Err(_) => return Ok(None), // Branch deleted or unavailable
    };

    let last_commit = patch.revisions.last().map(|r| r.commit.as_str()).unwrap_or("");
    let tip_hex = tip_oid.to_string();

    if tip_hex == last_commit {
        return Ok(None);
    }

    // Branch tip changed — insert a PatchRevision event
    let commit = repo.find_commit(tip_oid)?;
    let tree_oid = commit.tree()?.id();
    let author = get_author(repo)?;
    let timestamp = chrono::Utc::now().to_rfc3339();
    let event = Event {
        timestamp: timestamp.clone(),
        author,
        action: Action::PatchRevision {
            commit: tip_hex.clone(),
            tree: tree_oid.to_string(),
            body: None,
        },
        clock: 0,
    };
    dag::append_event(repo, ref_name, &event, sk)?;
    Ok(Some(Revision {
        number: current_rev + 1,
        commit: tip_hex,
        tree: tree_oid.to_string(),
        body: None,
        timestamp,
    }))
}

/// Auto-detect revision changes and update the in-memory PatchState in place.
fn auto_detect_and_update(
    repo: &Repository,
    ref_name: &str,
    patch: &mut PatchState,
    sk: &ed25519_dalek::SigningKey,
) -> Result<(), Error> {
    if let Some(rev) = auto_detect_revision(repo, ref_name, patch, sk)? {
        patch.revisions.push(rev);
    }
    Ok(())
}

pub fn create(
    repo: &Repository,
    title: &str,
    body: &str,
    base_ref: &str,
    branch: &str,
    fixes: Option<&str>,
) -> Result<String, crate::error::Error> {
    // Reject creating a patch from the base branch
    if branch == base_ref {
        return Err(crate::error::Error::Cmd(
            "cannot create patch from base branch".to_string(),
        ));
    }

    // Verify branch exists and get tip
    let branch_ref = format!("refs/heads/{}", branch);
    let tip_oid = repo.refname_to_id(&branch_ref)
        .map_err(|e| crate::error::Error::Cmd(format!("branch '{}' not found: {}", branch, e)))?;

    // Check for duplicate: scan open patches for matching branch
    let patches = state::list_patches(repo)?;
    for p in &patches {
        if p.status == PatchStatus::Open && p.branch == branch {
            return Err(crate::error::Error::Cmd(format!(
                "patch already exists for branch '{}'",
                branch
            )));
        }
    }

    // Get commit and tree OIDs for revision 1
    let commit = repo.find_commit(tip_oid)?;
    let tree_oid = commit.tree()?.id();
    let base_oid = repo.refname_to_id(&format!("refs/heads/{}", base_ref))?;

    let oid = dag::create_root_action(
        repo,
        Action::PatchCreate {
            title: title.to_string(),
            body: body.to_string(),
            base_ref: base_ref.to_string(),
            branch: branch.to_string(),
            fixes: fixes.map(|s| s.to_string()),
            commit: tip_oid.to_string(),
            tree: tree_oid.to_string(),
            base_commit: Some(base_oid.to_string()),
        },
    )?;
    let id = oid.to_string();
    let ref_name = format!("refs/collab/patches/{}", id);
    repo.reference(&ref_name, oid, false, "patch create")?;
    Ok(id)
}

pub struct ListEntry {
    pub patch: PatchState,
    pub unread: Option<usize>,
}

/// Count events after the last-seen mark. Returns None if never viewed.
fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> {
    let seen_ref = format!("refs/collab/local/seen/patches/{}", id);
    let seen_oid = repo.refname_to_id(&seen_ref).ok()?;
    let ref_name = format!("refs/collab/patches/{}", id);
    let tip = repo.refname_to_id(&ref_name).ok()?;

    if seen_oid == tip {
        return Some(0);
    }

    let mut revwalk = repo.revwalk().ok()?;
    revwalk
        .set_sorting(git2::Sort::TOPOLOGICAL)
        .ok()?;
    revwalk.push(tip).ok()?;
    revwalk.hide(seen_oid).ok()?;
    Some(revwalk.count())
}

pub fn list(
    repo: &Repository,
    show_closed: bool,
    show_archived: bool,
    limit: Option<usize>,
    offset: Option<usize>,
    sort: SortMode,
) -> Result<Vec<ListEntry>, crate::error::Error> {
    let patches = if show_archived {
        state::list_patches_with_archived(repo)?
    } else {
        state::list_patches(repo)?
    };
    let filtered = cli::filter_sort_paginate(patches, show_closed, sort, offset, limit);
    let entries = filtered
        .into_iter()
        .map(|patch| {
            let unread = count_unread(repo, &patch.id);
            ListEntry { patch, unread }
        })
        .collect();
    Ok(entries)
}

pub fn list_to_writer(
    repo: &Repository,
    show_closed: bool,
    show_archived: bool,
    limit: Option<usize>,
    offset: Option<usize>,
    sort: SortMode,
    writer: &mut dyn std::io::Write,
) -> Result<(), crate::error::Error> {
    let entries = list(repo, show_closed, show_archived, limit, offset, sort)?;
    if entries.is_empty() {
        writeln!(writer, "No patches found.").ok();
        return Ok(());
    }
    for e in &entries {
        let p = &e.patch;
        let stale = match p.staleness(repo) {
            Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind),
            Ok(_) => String::new(),
            Err(_) => String::new(),
        };
        let unread = match e.unread {
            Some(n) if n > 0 => format!(" ({} new)", n),
            _ => String::new(),
        };
        writeln!(
            writer,
            "{:.8}  {:6}  {}  (by {}){}{}",
            p.id, p.status, p.title, p.author.name, stale, unread
        )
        .ok();
    }
    Ok(())
}

pub fn list_json(repo: &Repository, show_closed: bool, show_archived: bool, sort: SortMode) -> Result<String, crate::error::Error> {
    let entries = list(repo, show_closed, show_archived, None, None, sort)?;
    let patches: Vec<&PatchState> = entries.iter().map(|e| &e.patch).collect();
    Ok(serde_json::to_string_pretty(&patches)?)
}

pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let p = PatchState::from_ref(repo, &ref_name, &id)?;
    Ok(serde_json::to_string_pretty(&p)?)
}

pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let patch = PatchState::from_ref(repo, &ref_name, &id)?;
    // Mark as read: store current tip as seen
    let tip = repo.refname_to_id(&ref_name)?;
    let seen_ref = format!("refs/collab/local/seen/patches/{}", id);
    repo.reference(&seen_ref, tip, true, "mark seen")?;
    Ok(patch)
}

pub fn comment(
    repo: &Repository,
    id_prefix: &str,
    body: &str,
    file: Option<&str>,
    line: Option<u32>,
    target_revision: Option<u32>,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let mut patch = PatchState::from_ref(repo, &ref_name, &id)?;

    // Auto-detect revision (runs for all comment types to record branch changes)
    auto_detect_and_update(repo, &ref_name, &mut patch, &sk)?;

    let author = get_author(repo)?;

    let action = match (file, line) {
        (Some(f), Some(l)) => {
            // Determine revision for the inline comment
            let rev = if let Some(target) = target_revision {
                // Validate target revision exists
                if !patch.revisions.iter().any(|r| r.number == target) {
                    return Err(Error::Cmd(format!("revision {} not found", target)));
                }
                target
            } else {
                patch.revisions.last().map(|r| r.number).unwrap_or(1)
            };
            Action::PatchInlineComment {
                file: f.to_string(),
                line: l,
                body: body.to_string(),
                revision: rev,
            }
        }
        (Some(_), None) | (None, Some(_)) => {
            return Err(git2::Error::from_str(
                "--file and --line must both be provided for inline comments",
            )
            .into());
        }
        (None, None) => {
            // Thread comment — not revision-anchored
            if target_revision.is_some() {
                return Err(Error::Cmd(
                    "thread comments are not revision-scoped; use --file and --line for inline comments".to_string(),
                ));
            }
            Action::PatchComment {
                body: body.to_string(),
            }
        }
    };

    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action,
        clock: 0,
    };
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}

pub fn review(
    repo: &Repository,
    id_prefix: &str,
    verdict: ReviewVerdict,
    body: &str,
    target_revision: Option<u32>,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let mut patch = PatchState::from_ref(repo, &ref_name, &id)?;

    // Auto-detect revision
    auto_detect_and_update(repo, &ref_name, &mut patch, &sk)?;

    let rev = if let Some(target) = target_revision {
        if !patch.revisions.iter().any(|r| r.number == target) {
            return Err(Error::Cmd(format!("revision {} not found", target)));
        }
        target
    } else {
        patch.revisions.last().map(|r| r.number).unwrap_or(1)
    };

    let is_reject = verdict == ReviewVerdict::Reject;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author: author.clone(),
        action: Action::PatchReview {
            verdict,
            body: body.to_string(),
            revision: rev,
        },
        clock: 0,
    };
    dag::append_event(repo, &ref_name, &event, &sk)?;

    // A reject verdict also closes the patch
    if is_reject {
        let close_event = Event {
            timestamp: chrono::Utc::now().to_rfc3339(),
            author,
            action: Action::PatchClose {
                reason: Some(format!("Rejected: {}", body)),
            },
            clock: 0,
        };
        dag::append_event(repo, &ref_name, &close_event, &sk)?;
        // Archive the ref
        if ref_name.starts_with("refs/collab/patches/") {
            state::archive_patch_ref(repo, &id)?;
        }
    }

    Ok(())
}

pub fn revise(
    repo: &Repository,
    id_prefix: &str,
    body: Option<&str>,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let patch = PatchState::from_ref(repo, &ref_name, &id)?;

    // Resolve current branch tip
    let tip_oid = patch.resolve_head(repo)?;
    let tip_hex = tip_oid.to_string();
    let last_commit = patch.revisions.last().map(|r| r.commit.as_str()).unwrap_or("");

    if tip_hex == last_commit {
        let current_rev = patch.revisions.last().map(|r| r.number).unwrap_or(0);
        return Err(Error::Cmd(format!(
            "no changes since revision {}",
            current_rev
        )));
    }

    let commit = repo.find_commit(tip_oid)?;
    let tree_oid = commit.tree()?.id();
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::PatchRevision {
            commit: tip_hex,
            tree: tree_oid.to_string(),
            body: body.map(|s| s.to_string()),
        },
        clock: 0,
    };
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}


/// Generate a unified diff between a patch's base branch and head commit.
pub fn diff(
    repo: &Repository,
    id_prefix: &str,
    revision: Option<u32>,
    between: Option<(u32, Option<u32>)>,
) -> Result<String, Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let p = PatchState::from_ref(repo, &ref_name, &id)?;

    if let Some((from, to)) = between {
        let to_rev = to.unwrap_or_else(|| p.revisions.last().map(|r| r.number).unwrap_or(1));
        interdiff(repo, &p, from, to_rev)
    } else if let Some(rev) = revision {
        generate_diff_at_revision(repo, &p, rev)
    } else {
        generate_diff(repo, &p)
    }
}

/// Resolve the base tree for diffing: find the merge-base between the base branch
/// and the given head OID, falling back to the base branch tip if no merge-base exists.
fn resolve_base_tree<'a>(
    repo: &'a Repository,
    base_branch: &str,
    head_oid: Oid,
) -> Result<Option<git2::Tree<'a>>, Error> {
    let base_ref = format!("refs/heads/{}", base_branch);
    let base_oid = match repo.refname_to_id(&base_ref) {
        Ok(oid) => oid,
        Err(_) => return Ok(None),
    };
    let tree_source = repo.merge_base(base_oid, head_oid).unwrap_or(base_oid);
    Ok(Some(repo.find_commit(tree_source)?.tree()?))
}

/// Generate a diff string from a patch's base and head using three-dot (merge-base) diff.
pub fn generate_diff(repo: &Repository, patch: &state::PatchState) -> Result<String, Error> {
    let head_oid = patch.resolve_head(repo)?;
    let head_commit = repo.find_commit(head_oid)
        .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?;
    let head_tree = head_commit.tree()?;
    let base_tree = resolve_base_tree(repo, &patch.base_ref, head_oid)?;

    let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;
    format_diff(&git_diff)
}

/// Generate a diff for a specific revision against the base branch (historical full diff).
fn generate_diff_at_revision(
    repo: &Repository,
    patch: &PatchState,
    rev_number: u32,
) -> Result<String, Error> {
    let revision = patch.revisions.iter().find(|r| r.number == rev_number)
        .ok_or_else(|| Error::Cmd(format!("revision {} not found", rev_number)))?;

    let head_tree = repo.find_tree(Oid::from_str(&revision.tree)?)?;
    let commit_oid = Oid::from_str(&revision.commit)?;
    let base_tree = resolve_base_tree(repo, &patch.base_ref, commit_oid)?;

    let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;
    format_diff(&git_diff)
}

/// Compute the interdiff between two revisions.
pub fn interdiff(
    repo: &Repository,
    patch: &PatchState,
    from_rev: u32,
    to_rev: u32,
) -> Result<String, Error> {
    let from = patch.revisions.iter().find(|r| r.number == from_rev)
        .ok_or_else(|| Error::Cmd(format!("revision {} not found", from_rev)))?;
    let to = patch.revisions.iter().find(|r| r.number == to_rev)
        .ok_or_else(|| Error::Cmd(format!("revision {} not found", to_rev)))?;

    let from_tree_oid = Oid::from_str(&from.tree)?;
    let to_tree_oid = Oid::from_str(&to.tree)?;
    let from_tree = repo.find_tree(from_tree_oid)?;
    let to_tree = repo.find_tree(to_tree_oid)?;

    let git_diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)?;
    format_diff(&git_diff)
}

/// Format a git2::Diff as a unified diff string.
fn format_diff(git_diff: &git2::Diff) -> Result<String, Error> {
    let mut output = String::new();
    let mut lines = 0usize;
    git_diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
        if lines >= 5000 {
            return true;
        }
        let prefix = match line.origin() {
            '+' => "+",
            '-' => "-",
            ' ' => " ",
            _ => "",
        };
        output.push_str(prefix);
        if let Ok(content) = std::str::from_utf8(line.content()) {
            output.push_str(content);
        }
        lines += 1;
        true
    })?;

    if lines >= 5000 {
        output.push_str("\n[truncated at 5000 lines]");
    }

    Ok(output)
}

/// Patch log: list all revisions with timestamps and file-change summaries.
pub fn patch_log(
    repo: &Repository,
    id_prefix: &str,
) -> Result<PatchState, Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    PatchState::from_ref(repo, &ref_name, &id)
}

pub fn patch_log_to_writer(
    repo: &Repository,
    patch: &PatchState,
    writer: &mut dyn std::io::Write,
) -> Result<(), Error> {
    if patch.revisions.is_empty() {
        writeln!(writer, "No revisions recorded.")?;
        return Ok(());
    }

    for (i, rev) in patch.revisions.iter().enumerate() {
        let short_oid = if rev.commit.len() >= 8 { &rev.commit[..8] } else { &rev.commit };
        let label = if i == 0 { " (initial)" } else { "" };
        let body_display = rev.body.as_deref().map(|b| format!("  \"{}\"", b)).unwrap_or_default();

        // Compute file-change summary between consecutive revisions
        let file_summary = if i > 0 {
            let prev = &patch.revisions[i - 1];
            match (Oid::from_str(&prev.tree), Oid::from_str(&rev.tree)) {
                (Ok(from_oid), Ok(to_oid)) => {
                    if let (Ok(from_tree), Ok(to_tree)) = (repo.find_tree(from_oid), repo.find_tree(to_oid)) {
                        if let Ok(diff) = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None) {
                            let stats = diff.stats().ok();
                            stats.map(|s| format!("  {} file(s) changed, +{} -{}", s.files_changed(), s.insertions(), s.deletions())).unwrap_or_default()
                        } else {
                            String::new()
                        }
                    } else {
                        String::new()
                    }
                }
                _ => String::new(),
            }
        } else {
            String::new()
        };

        writeln!(
            writer,
            "r{}  {}  {}{}{}{}",
            rev.number, short_oid, rev.timestamp, label, file_summary, body_display
        )?;
    }
    Ok(())
}

pub fn patch_log_json(patch: &PatchState) -> Result<String, Error> {
    Ok(serde_json::to_string_pretty(&patch.revisions)?)
}

pub fn checkout(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
    let (_ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let patch = show(repo, id_prefix)?;

    let latest_rev = patch
        .revisions
        .last()
        .ok_or_else(|| Error::Cmd("patch has no revisions".to_string()))?;
    let commit_oid = Oid::from_str(&latest_rev.commit)
        .map_err(|e| Error::Cmd(format!("invalid commit OID in revision: {}", e)))?;
    let commit = repo
        .find_commit(commit_oid)
        .map_err(|e| Error::Cmd(format!("commit {} not found: {}", &latest_rev.commit, e)))?;

    let short_id = &id[..std::cmp::min(8, id.len())];
    let branch_name = {
        let candidate = format!("collab/{}", short_id);
        if repo.find_branch(&candidate, git2::BranchType::Local).is_err() {
            candidate
        } else {
            let mut n = 1u32;
            loop {
                let suffixed = format!("collab/{}-{}", short_id, n);
                if repo.find_branch(&suffixed, git2::BranchType::Local).is_err() {
                    break suffixed;
                }
                n += 1;
            }
        }
    };

    repo.branch(&branch_name, &commit, false)?;

    let refname = format!("refs/heads/{}", branch_name);
    let obj = repo.revparse_single(&refname)?;
    repo.checkout_tree(&obj, None)?;
    repo.set_head(&refname)?;

    println!(
        "Checked out patch {} (revision {}) on branch {}",
        short_id, latest_rev.number, branch_name
    );
    Ok(())
}

pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    repo.find_reference(&ref_name)?.delete()?;
    Ok(id)
}

pub fn close(
    repo: &Repository,
    id_prefix: &str,
    reason: Option<&str>,
) -> Result<(), crate::error::Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    dag::append_action(repo, &ref_name, Action::PatchClose {
        reason: reason.map(|s| s.to_string()),
    })?;
    // Archive the ref (move to refs/collab/archive/patches/)
    if ref_name.starts_with("refs/collab/patches/") {
        state::archive_patch_ref(repo, &id)?;
    }
    Ok(())
}