use std::{path::PathBuf, process::Command};
use miette::Diagnostic;
use thiserror::Error;
use v_utils::{macros::wrap_err, prelude::*};
use super::{GitReader, Local, LocalFs, LocalIssueSource, LocalPath, consensus::is_git_initialized};
use crate::{Issue, IssueIndex, LazyIssue as _, RepoInfo, VirtualIssue, remote::Remote, sink::Sink};
#[wrap_err]
#[derive(Debug, Diagnostic, Error)]
#[error("Unresolved merge conflict")]
#[diagnostic(
code(tedi::conflict::unresolved),
help(
"Resolve the conflict in:\n {0}\n\n\
Options:\n\
1. Edit the file to resolve conflict markers (<<<<<<< ======= >>>>>>>)\n\
2. Use: git checkout --ours {0} (keep local)\n\
3. Use: git checkout --theirs {0} (keep remote)\n\
4. Use: git mergetool\n\n\
Then: git add {0} && git commit",
conflict_file.display()
)
)]
pub struct ConflictBlockedError {
pub conflict_file: PathBuf,
}
#[wrap_err]
#[derive(Debug, Error)]
pub enum ConflictError {
#[leaf]
#[error("git is not initialized in issues directory")]
GitNotInitialized,
#[leaf]
#[error("git operation failed: {message}")]
GitError { message: String },
#[foreign]
Io(std::io::Error),
}
pub fn conflict_file_path(owner: &str) -> PathBuf {
Local::issues_dir().join(owner).join("__conflict.md")
}
pub enum ConflictOutcome {
AutoMerged,
NeedsResolution,
NoChanges,
}
pub async fn check_for_existing_conflict(issue_index: IssueIndex) -> Result<Option<PathBuf>> {
let conflict_fpath = conflict_file_path(issue_index.owner());
if !conflict_fpath.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&conflict_fpath)?;
if has_conflict_markers(&content) {
Ok(Some(conflict_fpath))
} else {
{
let mut new_issue = {
let mut new_issue_but_old_local_timestamps = {
let virtual_issue = VirtualIssue::parse(&content, conflict_fpath)?;
let hollow = Local::read_hollow_from_project_meta(issue_index)?;
let project_meta = Local::load_project_meta(issue_index.repo_info());
Issue::from_combined(hollow, virtual_issue, issue_index.parent().unwrap(), project_meta.virtual_project)?
};
let last_consensus_issue = Issue::load(LocalIssueSource::<GitReader>::build(LocalPath::from(issue_index))?).await?;
new_issue_but_old_local_timestamps.post_update(&last_consensus_issue);
new_issue_but_old_local_timestamps
};
<Issue as Sink<LocalFs>>::sink(&mut new_issue, None).await?;
<Issue as Sink<Remote>>::sink(&mut new_issue, None).await?;
}
conflict_resolution_cleanup(issue_index.owner())?;
Ok(None)
}
}
pub fn has_conflict_markers(content: &str) -> bool {
let has_ours = content.contains("<<<<<<<");
let has_separator = content.contains("=======");
let has_theirs = content.contains(">>>>>>>");
has_ours && has_separator && has_theirs
}
pub fn is_merge_in_progress() -> bool {
let data_dir = Local::issues_dir();
let merge_head = data_dir.join(".git/MERGE_HEAD");
merge_head.exists()
}
pub fn initiate_conflict_merge(repo_info: RepoInfo, issue_number: u64, local_issue: &Issue, remote_issue: &Issue) -> Result<ConflictOutcome, ConflictError> {
if !is_git_initialized() {
return Err(ConflictError::new_git_not_initialized());
}
let owner = repo_info.owner();
let repo = repo_info.repo();
let data_dir = Local::issues_dir();
let data_dir_str = data_dir.to_str().ok_or_else(|| ConflictError::new_git_error("Invalid data directory path".into()))?;
let owner_dir = data_dir.join(owner);
std::fs::create_dir_all(&owner_dir)?;
let conflict_file = conflict_file_path(owner);
let conflict_file_rel = conflict_file.strip_prefix(&data_dir).unwrap_or(&conflict_file);
let conflict_file_rel_str = conflict_file_rel.to_string_lossy();
let branch_output = Command::new("git").args(["-C", data_dir_str, "rev-parse", "--abbrev-ref", "HEAD"]).output()?;
let current_branch = String::from_utf8_lossy(&branch_output.stdout).trim().to_string();
let local_virtual = local_issue.serialize_virtual();
std::fs::write(&conflict_file, &local_virtual)?;
let add_status = Command::new("git").args(["-C", data_dir_str, "add", "-A"]).status()?;
if !add_status.success() {
return Err(ConflictError::new_git_error("git add -A failed".into()));
}
let commit_msg = format!("__conflict: local state for {owner}/{repo}#{issue_number}");
let commit_output = Command::new("git").args(["-C", data_dir_str, "commit", "-m", &commit_msg]).output()?;
let local_committed = commit_output.status.success();
if local_committed {
tracing::debug!("[conflict] Committed local state");
}
let base_commit = if local_committed {
let parent_output = Command::new("git").args(["-C", data_dir_str, "rev-parse", "HEAD~1"]).output()?;
if parent_output.status.success() {
String::from_utf8_lossy(&parent_output.stdout).trim().to_string()
} else {
"HEAD".to_string()
}
} else {
"HEAD".to_string()
};
let _ = Command::new("git").args(["-C", data_dir_str, "branch", "-D", "remote-state"]).output();
let branch_status = Command::new("git").args(["-C", data_dir_str, "branch", "remote-state", &base_commit]).status()?;
if !branch_status.success() {
return Err(ConflictError::new_git_error("Failed to create remote-state branch".into()));
}
let checkout_status = Command::new("git").args(["-C", data_dir_str, "checkout", "remote-state"]).status()?;
if !checkout_status.success() {
cleanup_branch(data_dir_str, ¤t_branch);
return Err(ConflictError::new_git_error("Failed to checkout remote-state branch".into()));
}
let remote_virtual = remote_issue.serialize_virtual();
std::fs::write(&conflict_file, &remote_virtual)?;
let add_status = Command::new("git").args(["-C", data_dir_str, "add", "-A"]).status()?;
if !add_status.success() {
cleanup_branch(data_dir_str, ¤t_branch);
return Err(ConflictError::new_git_error("git add -A failed".into()));
}
let diff_status = Command::new("git").args(["-C", data_dir_str, "diff", "--cached", "--quiet"]).status()?;
if diff_status.success() {
let _ = Command::new("git").args(["-C", data_dir_str, "checkout", ¤t_branch]).status();
cleanup_branch(data_dir_str, ¤t_branch);
return Ok(ConflictOutcome::NoChanges);
}
let remote_commit_msg = format!("__conflict: remote state for {owner}/{repo}#{issue_number}");
let commit_status = Command::new("git").args(["-C", data_dir_str, "commit", "-m", &remote_commit_msg]).status()?;
if !commit_status.success() {
let _ = Command::new("git").args(["-C", data_dir_str, "checkout", ¤t_branch]).status();
cleanup_branch(data_dir_str, ¤t_branch);
return Err(ConflictError::new_git_error("Failed to commit remote state".into()));
}
let _ = Command::new("git").args(["-C", data_dir_str, "checkout", ¤t_branch]).status()?;
tracing::debug!("[conflict] Attempting merge of remote-state into {current_branch}");
let merge_output = Command::new("git")
.args([
"-C",
data_dir_str,
"merge",
"remote-state",
"-m",
&format!("Merge remote state for {owner}/{repo}#{issue_number}"),
])
.output()?;
if merge_output.status.success() {
cleanup_branch(data_dir_str, ¤t_branch);
tracing::debug!("[conflict] Merge succeeded automatically");
return Ok(ConflictOutcome::AutoMerged);
}
let stdout = String::from_utf8_lossy(&merge_output.stdout);
let stderr = String::from_utf8_lossy(&merge_output.stderr);
if stdout.contains("CONFLICT") || stderr.contains("CONFLICT") || stdout.contains("Automatic merge failed") {
tracing::debug!("[conflict] Merge produced conflicts in {conflict_file_rel_str}");
Ok(ConflictOutcome::NeedsResolution)
} else {
let _ = Command::new("git").args(["-C", data_dir_str, "merge", "--abort"]).status();
cleanup_branch(data_dir_str, ¤t_branch);
Err(ConflictError::new_git_error(format!("Merge failed: {}\n{}", stdout.trim(), stderr.trim())))
}
}
pub fn conflict_resolution_cleanup(owner: &str) -> Result<()> {
let data_dir = Local::issues_dir();
let data_dir_str = data_dir.to_str().ok_or_else(|| eyre!("Invalid data directory path"))?;
if is_merge_in_progress() {
bail!("Git merge is still in progress. Complete the merge first: git add <file> && git commit");
}
let _ = Command::new("git").args(["-C", data_dir_str, "branch", "-D", "remote-state"]).output();
{
let conflict_file = conflict_file_path(owner);
if conflict_file.exists() {
std::fs::remove_file(&conflict_file)?;
}
}
Ok(())
}
fn cleanup_branch(data_dir_str: &str, _current_branch: &str) {
let _ = Command::new("git").args(["-C", data_dir_str, "branch", "-D", "remote-state"]).output();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_has_conflict_markers() {
assert!(!has_conflict_markers("# Normal issue\n\nSome body text."));
let content = r#"# Issue title
<<<<<<< HEAD
Local changes
=======
Remote changes
>>>>>>> remote-state
"#;
assert!(has_conflict_markers(content));
assert!(!has_conflict_markers("# Issue\n\n=======\n\nSome divider"));
assert!(!has_conflict_markers("<<<<<<< HEAD\nSome text\n======="));
}
#[test]
fn test_conflict_file_path() {
let path = conflict_file_path("myowner");
assert!(path.to_string_lossy().contains("myowner"));
assert!(path.to_string_lossy().ends_with("__conflict.md"));
}
}