use std::path::PathBuf;
use argonauts_core::events::RunEventKind;
use argonauts_core::events::approval::{
AnswerAction, AnswerOption, ApprovalKind, ApprovalPacket, BothChanged, FreeText,
};
use argonauts_core::names::ApprovalId;
use argonauts_core::paths::{create_private_dir_all, write_private_file};
use argonauts_core::policy::{EvidenceDocument, EvidenceFacts};
use argonauts_core::settings::protocol::MergeMode;
use argonauts_core::{Note, NoteLevel, hidden_git};
use crate::approvals::{self, ApprovalRequest};
use crate::engine::host::Host;
use crate::error::EngineResult;
use crate::git::FolderMerge;
use crate::merge::deliver;
use crate::merge::in_the_way::{self, MERGE_LATER};
use crate::state::RunState;
pub const TITLE: &str = "Write this run into your folder?";
pub const WRITE_IT: &str = "Write it into my folder";
pub const KEEP_MINE: &str = "Keep my version";
pub const TAKE_THE_RUNS: &str = "Take the run's version";
pub const SHOW_BOTH: &str = "Show both";
pub fn is(host: &Host) -> bool {
host.git().hidden_history().is_some()
}
pub fn say_if_it_has_git_now(host: &Host) {
if is(host) && host.repo().root.join(".git").exists() {
let note = Note {
level: NoteLevel::Info,
text: "This folder has git now. This run kept to the hidden history it started \
with, and the next workflow uses your repository."
.to_owned(),
};
let _ = host.record(None, None, RunEventKind::Note(note));
}
}
pub fn merge_mode(host: &Host) -> MergeMode {
if is(host) {
MergeMode::Ask
} else {
host.settings().merge.mode
}
}
pub fn clear_stale_lock(host: &Host) {
let Some(hidden) = host.git().hidden_history() else {
return;
};
let now = std::time::SystemTime::now();
if hidden_git::clear_stale_locks(hidden, now, hidden_git::Writers::Maybe) {
let note = Note {
level: NoteLevel::Warn,
text: hidden_git::CLEARED_STALE_LOCK.to_owned(),
};
let _ = host.record(None, None, RunEventKind::Note(note));
}
}
pub fn merge_options(host: &Host) -> Option<Vec<AnswerOption>> {
let option = |key: char, label: &str, action: AnswerAction| AnswerOption {
key,
label: label.to_owned(),
action,
recommended: false,
detail: None,
};
is(host).then(|| {
vec![
option('y', WRITE_IT, AnswerAction::Merge),
option(
'b',
"Request changes on some tasks",
AnswerAction::RequestChanges,
),
option('x', "Abandon the run", AnswerAction::Abandon),
option('d', "Show the diff", AnswerAction::ShowDiff),
]
})
}
pub fn without_branch_answers(host: &Host, mut options: Vec<AnswerOption>) -> Vec<AnswerOption> {
if is(host) {
options.retain(|option| !needs_a_branch(&option.action));
}
options
}
pub fn needs_a_branch(action: &AnswerAction) -> bool {
matches!(action, AnswerAction::PullRequest | AnswerAction::KeepBranch)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Settled {
Written(String),
Later,
InTheWay,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Side {
Yours,
Runs,
}
enum Asked {
Side(Side),
Later,
Withdrawn,
}
pub async fn settle(
host: &Host,
approved: &ApprovalId,
main: &str,
head: &str,
) -> EngineResult<Settled> {
let git = host.git();
let repo = host.repo();
let root = &repo.root;
let scratch = repo.run_dir.join("your-folder.index");
let mut picked: Option<(Side, Vec<String>)> = host.read(recorded_pick);
loop {
let yours = git.folder_as_commit(root, &scratch, main).await?;
if git.tree_of(root, &yours).await? == git.tree_of(root, main).await? {
return Ok(Settled::InTheWay);
}
let merge = git.merge_folder(root, &yours, head).await?;
let side = match &picked {
_ if merge.both_changed.is_empty() => None,
Some((side, files)) if *files == merge.both_changed => Some(*side),
_ => match ask(host, approved, &merge, (main, &yours, head)).await? {
Asked::Later => return Ok(Settled::Later),
Asked::Withdrawn => continue,
Asked::Side(side) => {
picked = Some((side, merge.both_changed.clone()));
continue;
}
},
};
let tree = match side {
None => merge.tree.clone(),
Some(side) => {
let version = match side {
Side::Yours => yours.as_str(),
Side::Runs => head,
};
git.tree_with(root, &scratch, &merge.tree, version, &merge.both_changed)
.await?
}
};
let message = deliver::plain_message(host);
let written = git
.commit_tree_as_argonauts(root, &tree, &yours, &message)
.await?;
git.write_files(root, &scratch, &yours, &written, &[])
.await?;
git.follow_folder(root, &written).await?;
say(host, &merge, side, (main, &yours, head)).await;
return Ok(Settled::Written(written));
}
}
fn recorded_pick(state: &RunState) -> Option<(Side, Vec<String>)> {
state
.approvals
.by_id
.values()
.filter_map(|record| {
let ApprovalPacket::Merge(packet) = &record.opened.packet else {
return None;
};
let side = match &record.decided.as_ref()?.action {
AnswerAction::Choose { value } if value == KEEP_MINE => Side::Yours,
AnswerAction::Choose { value } if value == TAKE_THE_RUNS => Side::Runs,
_ => return None,
};
let files: Vec<String> = packet
.both_changed
.iter()
.map(|file| file.path.clone())
.collect();
(!files.is_empty()).then_some((record.since, side, files))
})
.max_by_key(|(since, ..)| *since)
.map(|(_, side, files)| (side, files))
}
async fn ask(
host: &Host,
approved: &ApprovalId,
merge: &FolderMerge,
(main, yours, head): (&str, &str, &str),
) -> EngineResult<Asked> {
let git = host.git();
let root = &host.repo().root;
let mut both_changed = Vec::new();
for path in &merge.both_changed {
let one = std::slice::from_ref(path);
both_changed.push(BothChanged {
path: path.clone(),
yours: git.diff_stat_of(root, main, yours, one).await?,
runs: git.diff_stat_of(root, main, head, one).await?,
});
}
let beside = git.diff_of(root, yours, head, &merge.both_changed).await?;
let request = request(host, approved, both_changed, &beside);
let decided = approvals::open(host, request)?
.decided_or_withdrawn(host)
.await?;
Ok(match decided.map(|decided| decided.action) {
Ok(AnswerAction::Choose { value }) if value == KEEP_MINE => Asked::Side(Side::Yours),
Ok(AnswerAction::Choose { value }) if value == TAKE_THE_RUNS => Asked::Side(Side::Runs),
Ok(AnswerAction::Choose { value }) if value == MERGE_LATER => Asked::Later,
_ => Asked::Withdrawn,
})
}
fn named(files: &[String]) -> String {
match files {
[one] => one.clone(),
files => format!("{} files", files.len()),
}
}
fn request(
host: &Host,
approved: &ApprovalId,
both_changed: Vec<BothChanged>,
beside: &str,
) -> ApprovalRequest {
let files: Vec<String> = both_changed.iter().map(|file| file.path.clone()).collect();
let one = files.len() == 1;
let name = named(&files);
let run = host.run_id();
let mut packet = in_the_way::packet_shown(host, approved, hidden_git::BRANCH);
packet.diff_path = beside_file(host, beside).unwrap_or_default();
packet.both_changed = both_changed;
let choice = |key: char, label: &str, detail: String| AnswerOption {
key,
label: label.to_owned(),
action: AnswerAction::Choose {
value: label.to_owned(),
},
recommended: false,
detail: Some(detail),
};
let options = vec![
choice(
'k',
KEEP_MINE,
if one {
format!("your edit stays, and the run's change to {name} is left out")
} else {
format!("your edits stay, and the run's changes to those {name} are left out")
},
),
choice(
't',
TAKE_THE_RUNS,
if one {
format!("the run's {name} goes in; yours stays in the history for /rewind")
} else {
format!("the run's {name} go in; yours stay in the history for /rewind")
},
),
AnswerOption {
key: 'd',
label: SHOW_BOTH.to_owned(),
action: AnswerAction::ShowDiff,
recommended: false,
detail: Some(format!(
"your {name} beside the run's, then these choices again"
)),
},
choice(
'l',
MERGE_LATER,
format!("the run pauses here; /resume {run} writes it in"),
),
];
let title = if one {
format!("You and the run both changed {name}. Which version goes in?")
} else {
format!("You and the run both changed {name}. Which versions go in?")
};
ApprovalRequest {
at: None,
kind: ApprovalKind::Merge,
title,
task: None,
packet: ApprovalPacket::Merge(packet),
facts: EvidenceFacts::default(),
agent_outputs: Vec::new(),
documents: vec![EvidenceDocument {
label: format!("your {name} beside the run's"),
text: beside.to_owned(),
path: None,
sha256: None,
}],
options: Some(options),
free_text: FreeText::Guidance,
authors: Vec::new(),
}
}
fn beside_file(host: &Host, beside: &str) -> Option<PathBuf> {
let folder = host.repo().run_dir.join("merge");
let file = folder.join("both-changed.diff");
let written =
create_private_dir_all(&folder).and_then(|()| write_private_file(&file, beside.as_bytes()));
match written {
Ok(()) => Some(file),
Err(err) => {
tracing::warn!("writing {}: {err}", file.display());
None
}
}
}
async fn say(host: &Host, merge: &FolderMerge, side: Option<Side>, range: (&str, &str, &str)) {
let (main, yours, head) = range;
let git = host.git();
let root = &host.repo().root;
let name = named(&merge.both_changed);
let text = match side {
Some(Side::Yours) => format!(
"Kept your version of {name}, and left the run's change to it out. The run's \
checks ran with its own version."
),
Some(Side::Runs) => format!(
"Took the run's version of {name}. Yours is in the history, and /rewind gets it \
back."
),
None => {
let (Ok(mine), Ok(runs)) = (
git.diff_files(root, main, yours).await,
git.diff_files(root, main, head).await,
) else {
return;
};
let both: Vec<String> = mine
.into_iter()
.map(|(path, ..)| path)
.filter(|path| runs.iter().any(|(theirs, ..)| theirs == path))
.collect();
if both.is_empty() {
return;
}
format!(
"You changed {} while the run worked, in other lines than it did, so your \
edits and the run's are both in. The run's checks ran without yours.",
named(&both)
)
}
};
let note = Note {
level: NoteLevel::Info,
text,
};
let _ = host.record(None, None, RunEventKind::Note(note));
}