use color_eyre::eyre::{Result, bail};
use tedi::{
HollowIssue, Issue, IssueIndex, IssueLink, IssueSelector, LazyIssue, RepoInfo, VirtualIssue,
local::{
Consensus, FsReader, Local, LocalFs, LocalIssueSource, LocalPath,
conflict::{ConflictOutcome, initiate_conflict_merge},
consensus::load_consensus_issue,
},
remote::{Remote, RemoteSource},
sink::Sink,
};
use tracing::instrument;
pub use types::*;
use v_utils::elog;
use super::merge::Merge;
#[instrument(skip_all, fields(
repo = ?issue.identity.repo_info(),
issue_id = ?issue.git_id(),
title = %issue.contents.title,
offline,
modifier = ?modifier,
))]
pub async fn modify_and_sync_issue(mut issue: Issue, offline: bool, modifier: Modifier, sync_opts: SyncOptions) -> Result<ModifyResult> {
let repo_info = issue.identity.repo_info();
let issue_index = IssueIndex::from(&issue);
if !offline && issue.is_linked() {
let consensus = load_consensus_issue(issue_index).await?;
let local_differs = consensus.as_ref().map(|c| *c != issue).unwrap_or(false);
if sync_opts.pull || local_differs {
elog!("triggered pre-open sync");
core::sync(&mut issue, consensus, sync_opts.take_merge_mode()).await?;
}
}
let new_modified = {
let result = modifier.apply(&mut issue).await?;
if !result.file_modified {
v_utils::log!("Aborted (no changes made)");
return Ok(result);
}
let cache_path = v_utils::xdg_cache_file!("last_modified_issue");
std::fs::write(&cache_path, issue.full_index().to_string()).ok();
result
};
match offline || Local::is_virtual_project(repo_info) {
true => {
<Issue as Sink<LocalFs>>::sink(&mut issue, None).await?;
println!("Offline: saved locally and exiting.");
return Ok(new_modified);
}
false => {
let mode = sync_opts.take_merge_mode();
match issue.is_linked() {
true => {
let consensus = load_consensus_issue(issue_index).await?;
core::sync(&mut issue, consensus, mode).await?;
}
false => {
let parent_index = issue.identity.parent_index;
if let Some((i, _)) = parent_index.index().iter().enumerate().find(|(_, s)| matches!(s, IssueSelector::Title(_))) {
<Issue as Sink<LocalFs>>::sink(&mut issue, None).await?;
let ancestor_index = IssueIndex::with_index(repo_info, parent_index.index()[..=i].to_vec());
let ancestor_source = LocalIssueSource::<FsReader>::build(LocalPath::new(ancestor_index)).await?;
let mut ancestor = Issue::load(ancestor_source).await?;
let old_ancestor = ancestor.clone();
<Issue as Sink<Remote>>::sink(&mut ancestor, None).await?;
<Issue as Sink<LocalFs>>::sink(&mut ancestor, Some(&old_ancestor)).await?;
<Issue as Sink<Consensus>>::sink(&mut ancestor, None).await?;
} else {
<Issue as Sink<Remote>>::sink(&mut issue, None).await?;
<Issue as Sink<LocalFs>>::sink(&mut issue, None).await?;
<Issue as Sink<Consensus>>::sink(&mut issue, None).await?;
}
}
}
Ok(new_modified)
}
}
}
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
#[error(transparent)]
#[diagnostic(help("Your changes were saved to /tmp/tedi/rejected-changes.md — you can recover them from there."))]
struct RejectedEdit(#[from] tedi::ParseError);
mod core {
use super::*;
#[instrument(skip_all, fields(?mode))]
pub(super) async fn resolve_merge(local: Issue, consensus: Option<Issue>, remote: Issue, mode: MergeMode, repo_info: RepoInfo, issue_number: u64) -> Result<(Issue, bool)> {
if let MergeMode::Reset { prefer } = mode {
return match prefer {
Side::Local => {
let mut resolved = local;
<Issue as Sink<Remote>>::sink(&mut resolved, Some(&remote)).await?;
Ok((resolved, true))
}
Side::Remote => {
let mut resolved = remote;
<Issue as Sink<LocalFs>>::sink(&mut resolved, None).await?;
Ok((resolved, true))
}
};
}
let (force_local_wins, force_remote_wins) = match mode {
MergeMode::Force { prefer: Side::Local } => (true, false),
MergeMode::Force { prefer: Side::Remote } => (false, true),
_ => (false, false),
};
let mut local_merged = local.clone();
let mut remote_merged = remote.clone();
if let Some(ref consensus) = consensus {
local_merged.merge(consensus, false)?;
}
local_merged.merge(&remote, force_remote_wins)?;
if let Some(consensus) = consensus {
remote_merged.merge(&consensus, false)?;
}
remote_merged.merge(&local, force_local_wins)?;
match local_merged == remote_merged {
true => {
let mut resolved = local_merged;
<Issue as Sink<LocalFs>>::sink(&mut resolved, None).await?;
<Issue as Sink<Remote>>::sink(&mut resolved, Some(&remote)).await?;
Ok((resolved, true))
}
false => {
match initiate_conflict_merge(repo_info, issue_number, &local_merged, &remote_merged)? {
ConflictOutcome::AutoMerged => {
unreachable!(
"AutoMerged means when we triggered a merge of local against remote (which we've already checked are divergent), it succeeded. Which would be an implementation error, - whole point of the call is to record the conflict before getting user to resolve it manually."
);
}
ConflictOutcome::NeedsResolution => {
bail!(
"Conflict detected for {}/{}#{issue_number}.\n\
Resolve using standard git tools, then re-run.",
repo_info.owner(),
repo_info.repo()
);
}
ConflictOutcome::NoChanges => {
Ok((local_merged, false))
}
}
}
}
}
#[instrument(skip_all, fields(?mode, has_consensus = consensus.is_some()))]
pub(super) async fn sync(current_issue: &mut Issue, consensus: Option<Issue>, mode: MergeMode) -> Result<()> {
println!("Syncing...");
let issue_number = current_issue.git_id().expect(
"can't be linked and not have number associated\nunless we die in a weird moment I guess. If this ever triggers, should fix it to set issue as pending (not linked) and sink",
);
let repo_info = current_issue.repo_info();
let url = format!("https://github.com/{}/{}/issues/{issue_number}", repo_info.owner(), repo_info.repo());
let link = IssueLink::parse(&url).expect("valid URL");
let remote_source = RemoteSource::build(link, Some(¤t_issue.identity.git_lineage()?))?; let remote = Issue::load(remote_source).await?;
let (resolved, changed) = core::resolve_merge(current_issue.clone(), consensus, remote, mode, repo_info, issue_number).await?;
*current_issue = resolved;
match changed {
true => {
<Issue as Sink<LocalFs>>::sink(current_issue, None).await?;
<Issue as Sink<Consensus>>::sink(current_issue, None).await?;
}
false => println!("No changes."),
}
Ok(())
}
}
mod types {
use super::*;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Side {
Local,
Remote,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum MergeMode {
#[default]
Normal,
Force { prefer: Side },
Reset { prefer: Side },
}
#[derive(Debug, Default)]
pub struct SyncOptions {
merge_mode: std::cell::Cell<Option<MergeMode>>,
pub pull: bool,
}
impl SyncOptions {
pub fn new(merge_mode: Option<MergeMode>, pull: bool) -> Self {
Self {
merge_mode: std::cell::Cell::new(merge_mode),
pull,
}
}
pub fn take_merge_mode(&self) -> MergeMode {
self.merge_mode.take().unwrap_or_default()
}
}
pub struct ModifyResult {
pub output: Option<String>,
pub file_modified: bool,
}
#[derive(Debug)]
pub enum Modifier {
Editor {
open_at_blocker: bool,
},
BlockerPop {
parents: usize,
},
BlockerAdd {
text: String,
nest: bool,
},
BlockerSet {
text: String,
},
BlockerWrite {
blockers: tedi::BlockerSequence,
},
MockGhostEdit,
}
impl Modifier {
#[tracing::instrument(skip_all)]
pub(super) async fn apply(&self, issue: &mut Issue) -> Result<ModifyResult> {
let old_issue = issue.clone();
let vpath = Local::virtual_edit_path(issue);
let result = match self {
Modifier::Editor { open_at_blocker } => {
let content = issue.serialize_virtual();
std::fs::write(&vpath, &content)?;
let mtime_before = std::fs::metadata(&vpath)?.modified()?;
let position = if *open_at_blocker {
issue.find_last_blocker_position().map(|(line, col)| crate::utils::Position::new(line, Some(col)))
} else {
None
};
crate::utils::open_file(&vpath, position).await?;
let mtime_after = std::fs::metadata(&vpath)?.modified()?;
let file_modified = mtime_after != mtime_before;
let content = std::fs::read_to_string(&vpath)?;
let (content, file_modified) = {
let trimmed = content.trim_end();
let undo = trimmed.strip_suffix("!u").or_else(|| trimmed.strip_suffix("!U"));
match undo {
Some(before) if before.is_empty() || before.ends_with('\n') => (before.trim_end_matches('\n').to_string(), false),
_ => (content, file_modified),
}
};
tracing::Span::current().record("vpath", tracing::field::debug(&vpath));
tracing::Span::current().record("content", content.as_str());
let parent_idx = issue.identity.parent_index;
let is_virtual = issue.identity.is_virtual;
let hollow: HollowIssue = old_issue.clone().into();
let virtual_issue = VirtualIssue::parse(&content, vpath.clone()).map_err(|e| {
crate::utils::persist_rejected_changes(&content);
RejectedEdit(e)
})?;
*issue = Issue::from_combined(hollow, virtual_issue, parent_idx, is_virtual)?;
ModifyResult { output: None, file_modified }
}
Modifier::BlockerPop { parents } => {
use crate::blocker_interactions::BlockerSequenceExt;
let popped = issue
.contents
.blockers
.pop(*parents)
.ok_or_else(|| color_eyre::eyre::eyre!("Cannot pop {parents} parents — blocker chain is shorter"))?;
ModifyResult {
output: Some(format!("Popped: {popped}")),
file_modified: true,
}
}
Modifier::BlockerAdd { text, nest } => {
use crate::blocker_interactions::BlockerSequenceExt;
if *nest {
issue.contents.blockers.add_child(text);
} else {
issue.contents.blockers.add(text);
}
ModifyResult { output: None, file_modified: true }
}
Modifier::BlockerSet { text } => {
use crate::blocker_interactions::BlockerSequenceExt;
let old = issue.contents.blockers.set(text);
ModifyResult {
output: old.map(|prev| format!("Replaced: {prev} -> {text}")),
file_modified: true,
}
}
Modifier::BlockerWrite { blockers } => {
let file_modified = issue.contents.blockers != *blockers;
issue.contents.blockers = blockers.clone();
ModifyResult { output: None, file_modified }
}
Modifier::MockGhostEdit => ModifyResult { output: None, file_modified: true },
};
if result.file_modified {
issue.post_update(&old_issue);
}
Ok(result)
}
}
}