use crate::child_environment::{CHILD_SET_PREFIX, environment_value};
use crate::entry::run_process;
use crate::forwarded_command::ResolvedCommand;
use crate::merge_isolation::{editor_would_open, stdin_and_stdout_are_one_terminal};
use std::ffi::OsString;
use std::io::{IsTerminal, Write};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
static NESTED_FINISHES: AtomicUsize = AtomicUsize::new(0);
static FINISH_BUDGET: AtomicUsize = AtomicUsize::new(0);
pub fn split_cmdline(text: &str) -> Option<Vec<OsString>> {
let mut words: Vec<String> = Vec::new();
let mut current: String = String::new();
let mut started: bool = false;
let mut quoted: Option<char> = None;
let mut characters = text.chars();
while let Some(character) = characters.next() {
if quoted.is_none() && character.is_whitespace() {
if started {
words.push(std::mem::take(&mut current));
started = false;
}
} else if quoted.is_none() && (character == '\'' || character == '"') {
quoted = Some(character);
started = true;
} else if Some(character) == quoted {
quoted = None;
} else {
let mut kept: char = character;
if character == '\\' && quoted != Some('\'') {
kept = characters.next()?;
}
current.push(kept);
started = true;
}
}
if quoted.is_some() {
return None;
}
if started {
words.push(current);
}
return Some(words.into_iter().map(OsString::from).collect());
}
fn todo_commands(path: &Path) -> usize {
return std::fs::read_to_string(path)
.map(|text| { return text.lines().filter(|line| { let trimmed: &str = line.trim(); return !trimmed.is_empty() && !trimmed.starts_with('#'); }).count(); })
.unwrap_or(0);
}
fn finish_budget(operation: Operation, git_dir: &Path) -> usize {
return match operation {
Operation::Rebase => todo_commands(git_dir.join("rebase-merge/git-rebase-todo").as_path()) + 1,
Operation::Pick => todo_commands(git_dir.join("sequencer/todo").as_path()).max(1),
Operation::Merge => 1,
};
}
fn nesting_allows(running: usize, budget: usize) -> bool {
return running < budget;
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Stops {
pub no_commit: bool,
pub squash: bool,
pub edit: Option<bool>,
pub message: bool,
pub passed_on: Vec<OsString>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Operation {
Merge,
Rebase,
Pick,
}
const HARMLESS_MERGE_OPTIONS: [&str; 21] = [
"-v", "--verbose", "--no-verbose", "--progress", "--no-progress", "--no-verify", "--verify", "--summary", "--no-summary",
"--no-signoff", "--no-verify-signatures", "--ff", "--no-ff", "--ff-only", "--allow-unrelated-histories", "--stat",
"--no-stat", "--log", "--no-log", "--quiet", "--autostash",
];
const HARMLESS_PULL_OPTIONS: [&str; 17] = [
"--rebase", "-r", "--no-rebase", "--rebase=true", "--rebase=false", "--rebase=merges", "--all", "--prune", "-p", "--no-prune",
"--tags", "-t", "--no-tags", "--force", "-f", "--no-recurse-submodules", "--ipv4",
];
const HARMLESS_PICK_OPTIONS: [&str; 13] = [
"-x", "--ff", "--allow-empty", "--allow-empty-message", "--keep-redundant-commits", "--rerere-autoupdate",
"--no-rerere-autoupdate", "--reference", "-q", "--quiet", "-v", "--verbose", "--no-verbose",
];
const VALUE_OPTIONS: [&str; 4] = ["-s", "--strategy", "-X", "--strategy-option"];
pub fn merge_stops(region: &[OsString], pull: bool) -> Option<Stops> {
let mut stops: Stops = Stops::default();
let mut tokens = region.iter();
while let Some(token) = tokens.next() {
let text: &str = token.to_str()?;
match text {
"--no-commit" => stops.no_commit = true,
"--commit" => stops.no_commit = false,
"--squash" => stops.squash = true,
"--no-squash" => stops.squash = false,
"-e" | "--edit" => stops.edit = Some(true),
"--no-edit" => stops.edit = Some(false),
"-m" | "--message" | "-F" | "--file" => {
tokens.next()?;
stops.message = true;
}
"--signoff" | "--no-gpg-sign" => stops.passed_on.push(token.clone()),
other if other.starts_with("--message=") || other.starts_with("--file=") => stops.message = true,
other if other.starts_with("-S") || other.starts_with("--gpg-sign") => stops.passed_on.push(token.clone()),
other if VALUE_OPTIONS.contains(&other) => {
tokens.next()?;
}
other if other.starts_with("--strategy=") || other.starts_with("--strategy-option=") => {}
other if HARMLESS_MERGE_OPTIONS.contains(&other) => {}
other if pull && HARMLESS_PULL_OPTIONS.contains(&other) => {}
other if other.starts_with('-') && other != "-" => return None,
_ => {}
}
}
return Some(stops);
}
pub fn pick_stops(region: &[OsString]) -> Option<(Stops, bool)> {
let mut stops: Stops = Stops::default();
let mut continuing: bool = false;
let mut tokens = region.iter();
while let Some(token) = tokens.next() {
let text: &str = token.to_str()?;
match text {
"-n" | "--no-commit" => stops.no_commit = true,
"-e" | "--edit" => stops.edit = Some(true),
"--no-edit" => stops.edit = Some(false),
"--continue" | "--skip" => continuing = true,
"-s" | "--signoff" => stops.passed_on.push(OsString::from("--signoff")),
"-m" | "--mainline" => {
tokens.next()?;
}
other if other.starts_with("--mainline=") || other.starts_with("--empty=") => {}
other if other.starts_with("-S") || other.starts_with("--gpg-sign") || other == "--no-gpg-sign" => stops.passed_on.push(token.clone()),
other if VALUE_OPTIONS.contains(&other) => {
tokens.next()?;
}
other if other.starts_with("--strategy=") || other.starts_with("--strategy-option=") => {}
other if HARMLESS_PICK_OPTIONS.contains(&other) => {}
other if other.starts_with('-') && other != "-" => return None,
_ => {}
}
}
return Some((stops, continuing));
}
pub fn saved_pick_stops(git_dir: &Path) -> Option<Stops> {
let text: String = std::fs::read_to_string(git_dir.join("sequencer/opts")).ok()?;
let mut stops: Stops = Stops::default();
for line in text.lines() {
let Some((name, value)) = line.split_once('=') else { continue };
match (name.trim(), value.trim()) {
("no-commit", "true") => stops.no_commit = true,
("edit", "true") => stops.edit = Some(true),
("edit", "false") => stops.edit = Some(false),
("signoff", "true") => stops.passed_on.push(OsString::from("--signoff")),
_ => {}
}
}
return Some(stops);
}
pub fn without_conflicts_hint(message: &[u8]) -> Vec<u8> {
let lines: Vec<&[u8]> = message.split_inclusive(|byte| { return *byte == b'\n'; }).collect();
let mut start: usize = lines.len();
let mut comment: Option<&[u8]> = None;
while start > 0 {
let line: &[u8] = lines[start - 1].strip_suffix(b"\n").unwrap_or(lines[start - 1]);
if let Some(prefix) = line.strip_suffix(b"Conflicts:") {
if comment.is_none_or(|known| { return known == prefix; }) && !prefix.is_empty() {
comment = Some(prefix);
start -= 1;
break;
}
return message.to_vec();
}
let Some(tab_path) = comment.map_or_else(|| { return Some(line); }, |known| { return line.strip_prefix(known); }) else { return message.to_vec() };
if comment.is_none() && !tab_path.contains(&b'\t') {
return message.to_vec();
}
start -= 1;
}
if comment.is_none() {
return message.to_vec();
}
if start > 0 && lines[start - 1] == b"\n" {
start -= 1;
}
return lines[..start].concat();
}
fn merge_edits_by_default(message: bool, environment: &[(OsString, OsString)]) -> Option<bool> {
if message {
return Some(false);
}
let auto_edit: Option<String> = environment_value(environment, "GIT_MERGE_AUTOEDIT").map(|value| { return value.to_string_lossy().into_owned(); });
return editor_would_open(auto_edit.as_deref(), stdin_and_stdout_are_one_terminal());
}
fn operation_in_progress(git_dir: &Path, command: &ResolvedCommand, squash: bool) -> Option<Operation> {
if git_dir.join("rebase-merge").is_dir() || git_dir.join("rebase-apply").is_dir() {
return Some(Operation::Rebase);
}
if git_dir.join("CHERRY_PICK_HEAD").exists() || git_dir.join("REVERT_HEAD").exists() {
return Some(Operation::Pick);
}
if git_dir.join("MERGE_HEAD").exists() || (squash && command.word() != b"cherry-pick" && command.word() != b"revert") {
return Some(Operation::Merge);
}
return None;
}
pub struct Finish {
operation: Operation,
stops: Stops,
git_dir: std::path::PathBuf,
}
pub fn plan_finish(command: &ResolvedCommand, environment: &[(OsString, OsString)], git_dir: &Path, configured_options: &[OsString]) -> Option<Finish> {
let region: &[OsString] = command.region();
let word: &[u8] = command.word();
let merge_region: Vec<OsString> = configured_options.iter().chain(region.iter()).cloned().collect();
let (mut stops, continuing): (Stops, bool) = match word {
b"merge" => (merge_stops(merge_region.as_slice(), false)?, false),
b"pull" => (merge_stops(merge_region.as_slice(), true)?, false),
b"rebase" => (Stops::default(), false),
b"cherry-pick" | b"revert" => pick_stops(region)?,
_ => return None,
};
let pick_without_commit: bool = matches!(word, b"cherry-pick" | b"revert") && stops.no_commit;
let operation: Operation = match operation_in_progress(git_dir, command, stops.squash) {
Some(found) => found,
None if pick_without_commit => Operation::Pick,
None => return None,
};
if !matches!((word, operation), (b"merge" | b"pull", Operation::Merge) | (b"pull" | b"rebase", Operation::Rebase) | (b"cherry-pick" | b"revert", Operation::Pick)) {
return None;
}
let running: usize = NESTED_FINISHES.load(Ordering::SeqCst);
if running == 0 {
FINISH_BUDGET.store(finish_budget(operation, git_dir), Ordering::SeqCst);
}
if !nesting_allows(running, FINISH_BUDGET.load(Ordering::SeqCst)) {
return None;
}
if continuing {
stops = Stops { passed_on: stops.passed_on.clone(), ..saved_pick_stops(git_dir)? };
}
if operation == Operation::Rebase && todo_needs_editor(git_dir) {
return None;
}
if operation == Operation::Merge && stops.edit.is_none() {
stops.edit = Some(merge_edits_by_default(stops.message, environment)?);
}
if operation == Operation::Pick {
let reverting: bool = word == b"revert" || git_dir.join("REVERT_HEAD").exists();
stops.edit = Some(stops.edit.unwrap_or(reverting && std::io::stdin().is_terminal()));
if stops.no_commit && git_dir.join("sequencer/todo").exists() {
return None;
}
}
return Some(Finish { operation, stops, git_dir: git_dir.to_path_buf() });
}
fn invocation(command: &ResolvedCommand, words: &[&str]) -> Vec<OsString> {
let mut arguments: Vec<OsString> = command.arguments[..command.subcommand_index].to_vec();
arguments.extend(words.iter().map(OsString::from));
return arguments;
}
fn todo_needs_editor(git_dir: &Path) -> bool {
let Ok(todo) = std::fs::read_to_string(git_dir.join("rebase-merge/git-rebase-todo")) else { return false };
return todo.lines().any(|line| {
let mut words = line.split_whitespace();
let command: &str = words.next().unwrap_or_default();
return matches!(command, "reword" | "r" | "squash" | "s") || (matches!(command, "fixup" | "f") && words.next().is_some_and(|option| { return option == "-c" || option == "-C"; }));
});
}
fn run_nested(arguments: &[OsString], environment: &[(OsString, OsString)]) -> i32 {
NESTED_FINISHES.fetch_add(1, Ordering::SeqCst);
let code: i32 = run_process(arguments, environment);
NESTED_FINISHES.fetch_sub(1, Ordering::SeqCst);
return code;
}
fn commit_step(finish: &Finish, command: &ResolvedCommand, environment: &[(OsString, OsString)]) -> i32 {
let mut words: Vec<&str> = vec!["commit"];
if finish.stops.edit != Some(true) {
words.extend(["--no-edit", if finish.operation == Operation::Merge { "--cleanup=whitespace" } else { "--cleanup=strip" }]);
}
let mut arguments: Vec<OsString> = invocation(command, words.as_slice());
arguments.extend(finish.stops.passed_on.iter().cloned());
return run_nested(arguments.as_slice(), environment);
}
pub fn finish(plan: &Finish, command: &ResolvedCommand, environment: &[(OsString, OsString)], stderr: &mut dyn Write) -> i32 {
let message_file = plan.git_dir.join("MERGE_MSG");
if plan.operation != Operation::Rebase
&& let Ok(message) = std::fs::read(&message_file)
{
let cleaned: Vec<u8> = without_conflicts_hint(message.as_slice());
if cleaned != message {
let result = if cleaned.is_empty() { std::fs::remove_file(&message_file) } else { std::fs::write(&message_file, cleaned) };
if let Err(error) = result {
crate::diagnostic_log::warn("wordMergeFinish", format!("the conflict hint could not be cut from {}: {error}", message_file.display()).as_str());
}
}
}
match plan.operation {
Operation::Rebase => {
let mut quiet: Vec<(OsString, OsString)> = environment.to_vec();
quiet.push((OsString::from(format!("{CHILD_SET_PREFIX}GIT_EDITOR")), OsString::from(":")));
return run_nested(invocation(command, &["rebase", "--continue"]).as_slice(), quiet.as_slice());
}
Operation::Merge if plan.stops.no_commit || plan.stops.squash => {
let _ = stderr.write_all(b"Automatic merge went well; stopped before committing as requested\n");
return 0;
}
Operation::Pick if plan.stops.no_commit => return 0,
_ => {}
}
let committed: i32 = commit_step(plan, command, environment);
if committed != 0 || plan.operation != Operation::Pick || !plan.git_dir.join("sequencer/todo").exists() {
return committed;
}
let word: &str = if command.word() == b"revert" || plan.git_dir.join("REVERT_HEAD").exists() { "revert" } else { "cherry-pick" };
return run_nested(invocation(command, &[word, "--continue"]).as_slice(), environment);
}
#[cfg(test)]
#[path = "merge_second_pass_finish_tests.rs"]
mod tests;