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(())
}