use crate::index_holder_wait::wait_for_index_release_here;
use crate::service_index_wait::wait_for_service_hold_here;
use crate::child_environment::environment_value;
use crate::diagnostic_log::debug;
use crate::forwarded_command::ResolvedCommand;
use crate::index_writer_forward::{CoordinatedForward, CoordinatedRun};
use crate::merge_isolation_message::fetch_head_merge_heads;
use crate::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
use crate::worktree_copy_forward::{ForwardedRun, run_forwarded_git};
use crate::worktree_identity::WorktreeIdentity;
use std::ffi::OsString;
use std::io::Write;
use std::path::Path;
const TAG: &str = "pullIsolation";
const IN_PROGRESS_MARKERS: [&str; 6] = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "sequencer"];
const MERGE_OPTIONS: [&str; 17] = [
"--no-edit", "--edit", "--ff", "--no-ff", "--stat", "--no-stat", "-n", "--log", "--no-log", "--commit", "--no-commit",
"--squash", "--no-squash", "--signoff", "--no-signoff", "--verify", "--no-verify",
];
const FETCH_OPTIONS: [&str; 13] = [
"--all", "--prune", "-p", "--no-prune", "--tags", "-t", "--no-tags", "--force", "-f", "--ipv4", "-4", "--ipv6", "-6",
];
const SHARED_OPTIONS: [&str; 4] = ["-q", "--quiet", "-v", "--verbose"];
const NATIVE_ONLY_SETTINGS: [&str; 3] = ["pull.autostash", "submodule.recurse", "fetch.recursesubmodules"];
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PullPlan {
pub fetch: Vec<OsString>,
pub merge: Vec<OsString>,
pub places: Vec<OsString>,
pub merge_stated: bool,
pub fast_forward_stated: bool,
}
pub fn plan_pull(region: &[OsString]) -> Option<PullPlan> {
let mut plan: PullPlan = PullPlan { fetch: Vec::new(), merge: Vec::new(), places: Vec::new(), merge_stated: false, fast_forward_stated: false };
for token in region {
let text: &str = token.to_str()?;
if text == "--no-rebase" || text == "--rebase=false" {
plan.merge_stated = true;
} else if MERGE_OPTIONS.contains(&text) {
plan.fast_forward_stated |= text == "--ff" || text == "--no-ff";
plan.merge.push(token.clone());
} else if FETCH_OPTIONS.contains(&text) {
plan.fetch.push(token.clone());
} else if SHARED_OPTIONS.contains(&text) {
plan.fetch.push(token.clone());
plan.merge.push(token.clone());
} else if text.starts_with('-') {
return None;
} else if !plan.places.is_empty() && text.contains(':') {
return None;
} else {
plan.places.push(token.clone());
}
}
return Some(plan);
}
fn says_no(value: &str) -> bool {
return matches!(value.to_ascii_lowercase().as_str(), "false" | "no" | "off" | "0" | "");
}
pub fn merge_is_stated(plan: &PullPlan, settings: &[(String, String)], branch: &str) -> bool {
if settings.iter().any(|(key, _)| { return NATIVE_ONLY_SETTINGS.contains(&key.as_str()); }) {
return false;
}
if !plan.fast_forward_stated && settings.iter().any(|(key, _)| { return key == "pull.ff"; }) {
return false;
}
let branch_key: String = format!("branch.{branch}.rebase");
let chosen: Option<&String> = settings
.iter()
.find(|(key, _)| { return *key == branch_key; })
.or_else(|| { return settings.iter().find(|(key, _)| { return key == "pull.rebase"; }); })
.map(|(_, value)| { return value; });
if plan.merge_stated {
return true;
}
return match chosen {
Some(value) => says_no(value.as_str()),
None => plan.fast_forward_stated,
};
}
fn ask(context: &GitContext, arguments: &[&str]) -> Option<GitOutput> {
return run_git(context, &GitRequest::new(Path::new("."), arguments)).ok();
}
fn pull_settings(context: &GitContext) -> Option<Vec<(String, String)>> {
let output: GitOutput = ask(
context,
&[
"config",
"--null",
"--get-regexp",
"^(pull\\.(rebase|ff|autostash)|submodule\\.recurse|fetch\\.recursesubmodules|branch\\..*\\.rebase)$",
],
)?;
if output.code != Some(0) && output.code != Some(1) {
return None;
}
let mut settings: Vec<(String, String)> = Vec::new();
for entry in output.stdout.split(|byte| { return *byte == 0; }).filter(|entry| { return !entry.is_empty(); }) {
let text: String = String::from_utf8_lossy(entry).into_owned();
let (key, value): (&str, &str) = text.split_once('\n').unwrap_or((text.as_str(), "true"));
settings.push((key.to_owned(), value.to_owned()));
}
return Some(settings);
}
fn line_of(output: Option<GitOutput>) -> Option<String> {
let found: GitOutput = output?;
if found.code != Some(0) {
return None;
}
return Some(String::from_utf8_lossy(&found.stdout).trim().to_owned());
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PullStep {
Native,
Ended(CoordinatedRun),
Merge {
forward: Box<CoordinatedForward>,
makes_commit: bool,
},
}
fn merge_forward(forward: &CoordinatedForward, plan: &PullPlan, overlay: Vec<(OsString, OsString)>) -> CoordinatedForward {
let mut arguments: Vec<OsString> = forward.command.arguments[..forward.command.subcommand_index].to_vec();
arguments.push(OsString::from("merge"));
arguments.extend(plan.merge.iter().cloned());
arguments.push(OsString::from("FETCH_HEAD"));
let command: ResolvedCommand = ResolvedCommand {
arguments: arguments.clone(),
subcommand: Some(OsString::from("merge")),
..forward.command.clone()
};
return CoordinatedForward { arguments, command, overlay, ..forward.clone() };
}
pub fn pull_step(forward: &CoordinatedForward, real_index: &Path) -> PullStep {
let Some(plan) = plan_pull(forward.command.region()) else {
debug(TAG, "native Git runs this pull: an option or operand is not read exactly");
return PullStep::Native;
};
let (git_dir, root): (&Path, &Path) = match &forward.identity {
WorktreeIdentity::MainWorktree { git_dir, worktree_root, .. } | WorktreeIdentity::LinkedWorktree { git_dir, worktree_root, .. } => {
(git_dir.as_path(), worktree_root.as_path())
}
_ => return PullStep::Native,
};
if IN_PROGRESS_MARKERS.iter().any(|name| { return git_dir.join(name).exists(); }) || root.join(".gitmodules").exists() {
debug(TAG, "native Git runs this pull: another operation is in progress, or the repository has submodules");
return PullStep::Native;
}
let context: GitContext = GitContext {
real_git: forward.real_git.clone(),
overlay: forward.overlay.clone(),
global_prefix: forward.command.arguments[..forward.command.subcommand_index].to_vec(),
common_dir: None,
};
let (Some(branch), Some(settings), Some(orig_head)) = (
line_of(ask(&context, &["symbolic-ref", "--quiet", "--short", "HEAD"])),
pull_settings(&context),
line_of(ask(&context, &["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])),
) else {
debug(TAG, "native Git runs this pull: no branch is checked out, or its head is unborn");
return PullStep::Native;
};
if !merge_is_stated(&plan, settings.as_slice(), branch.as_str()) {
debug(TAG, "native Git runs this pull: it is not stated to merge, or a setting adds steps this does not mirror");
return PullStep::Native;
}
let unmerged: Option<GitOutput> = ask(&context, &["ls-files", "--unmerged", "-z"]);
if unmerged.as_ref().is_none_or(|found| { return found.code != Some(0) || !found.stdout.is_empty(); }) {
debug(TAG, "native Git runs this pull: the index holds unmerged entries");
return PullStep::Native;
}
let mut overlay: Vec<(OsString, OsString)> = forward.overlay.clone();
if environment_value(forward.environment.as_slice(), "GIT_REFLOG_ACTION").is_none() {
let mut action: OsString = OsString::from("pull");
for token in forward.command.region() {
action.push(" ");
action.push(token);
}
overlay.push((OsString::from("GIT_REFLOG_ACTION"), action));
}
let mut fetch: Vec<OsString> = forward.command.arguments[..forward.command.subcommand_index].to_vec();
fetch.push(OsString::from("fetch"));
fetch.push(OsString::from("--update-head-ok"));
fetch.extend(plan.fetch.iter().cloned());
fetch.extend(plan.places.iter().cloned());
debug(TAG, "the fetch of a pull that merges runs, then the merge goes through the isolated merge");
match run_forwarded_git(forward.real_git.as_path(), fetch.as_slice(), overlay.as_slice(), None) {
ForwardedRun::Ran(0) => {}
ForwardedRun::Ran(_) => return PullStep::Ended(CoordinatedRun::Ran(1)),
ForwardedRun::Failed { stderr, code } => return PullStep::Ended(CoordinatedRun::Failed { stderr, code }),
}
let Some(curr_head) = line_of(ask(&context, &["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])) else {
return PullStep::Native;
};
if curr_head != orig_head {
let _noted = std::io::stderr().write_all(
format!("warning: fetch updated the current branch head.\nfast-forwarding your working tree from\ncommit {orig_head}.\n").as_bytes(),
);
wait_for_service_hold_here(real_index, "pull's merge");
wait_for_index_release_here(real_index, "pull's merge");
let moved: Option<GitOutput> = ask(&context, &["read-tree", "-u", "-m", orig_head.as_str(), curr_head.as_str()]);
if moved.is_none_or(|found| { return found.code != Some(0); }) {
return PullStep::Ended(CoordinatedRun::Failed {
stderr: format!(
"fatal: Cannot fast-forward your working tree.\nAfter making sure that you saved anything precious from\n$ git diff {orig_head}\noutput, run\n$ git reset --hard\nto recover.\n"
),
code: 128,
});
}
}
let fetched: String = std::fs::read_to_string(git_dir.join("FETCH_HEAD")).unwrap_or_default();
let heads: Vec<String> = fetch_head_merge_heads(fetched.as_str());
if heads.len() != 1 {
debug(TAG, "native Git runs the whole pull again: FETCH_HEAD does not list exactly one head to merge");
return PullStep::Native;
}
let bases: Option<GitOutput> = ask(&context, &["merge-base", "--all", "HEAD", heads[0].as_str()]);
let makes_commit: bool = bases.is_none_or(|found| {
let listed: String = String::from_utf8_lossy(&found.stdout).trim().to_owned();
return found.code != Some(0) || (listed != curr_head && listed != heads[0]);
});
return PullStep::Merge { forward: Box::new(merge_forward(forward, &plan, overlay)), makes_commit };
}
#[cfg(test)]
#[path = "pull_isolation_tests.rs"]
mod tests;