Skip to main content

git_policy_cli/merge/
pull_isolation.rs

1//! What:
2//!  A `git pull` that merges, turned into the fetch it starts with and the `git merge FETCH_HEAD` it ends with, so the merge
3//!  half goes through the same isolated merge as a typed `git merge`.
4//! Why:
5//!  Native Git's `git pull` runs its merge as a child of Git (`builtin/pull.c`, `run_merge`, `cmd.git_cmd = 1`), so the
6//!  wrapper never saw it and the merge kept native Git's failures: a staged index, a held `index.lock`, a late ref
7//!  update. The owner's rules are "Auto merging should never fail." and "Manual merge should never fail too."; the
8//!  pull's own decisions (which options, whether it rebases, whether `HEAD` moved under the fetch, how many heads
9//!  `FETCH_HEAD` holds) are mirrored here only where they can be mirrored exactly, and every other pull is left to native Git.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const plan = planPull(region); // undefined for anything not read exactly
14//! if (plan && mergeIsStated(plan, config)) { await fetch(plan); return merge(['--no-edit', 'FETCH_HEAD']); }
15//! return nativePull();
16//! ```
17
18/// The wait for any program's hold on the index lock, and for the commit service's own hold.
19use crate::index_holder_wait::wait_for_index_release_here;
20use crate::service_index_wait::wait_for_service_hold_here;
21/// The inherited environment, which decides whether the reflog message is already set.
22use crate::child_environment::environment_value;
23/// Diagnostics for the log.
24use crate::diagnostic_log::debug;
25/// The command a pull becomes.
26use crate::forwarded_command::ResolvedCommand;
27/// The forward being run and how it ended.
28use crate::index_writer_forward::{CoordinatedForward, CoordinatedRun};
29/// The heads `FETCH_HEAD` lists for merging.
30use crate::merge_isolation_message::fetch_head_merge_heads;
31/// Running real Git for the queries.
32use crate::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
33/// Running real Git as a child that owns the terminal, for the fetch.
34use crate::worktree_copy_forward::{ForwardedRun, run_forwarded_git};
35/// The repository shape of the invocation.
36use crate::worktree_identity::WorktreeIdentity;
37/// Owned operating-system text.
38use std::ffi::OsString;
39/// Standard error for the pull's own warning.
40use std::io::Write;
41/// Borrowed filesystem paths.
42use std::path::Path;
43
44/// Module tag for diagnostics.
45const TAG: &str = "pullIsolation";
46
47/// Files and directories of the Git directory that show another operation is in progress; native Git's pull reports those.
48const IN_PROGRESS_MARKERS: [&str; 6] = ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "sequencer"];
49
50/// The options of `git pull` that go to its merge as typed (`builtin/pull.c`, `run_merge`).
51const MERGE_OPTIONS: [&str; 17] = [
52    "--no-edit", "--edit", "--ff", "--no-ff", "--stat", "--no-stat", "-n", "--log", "--no-log", "--commit", "--no-commit",
53    "--squash", "--no-squash", "--signoff", "--no-signoff", "--verify", "--no-verify",
54];
55
56/// The options of `git pull` that go to its fetch as typed (`builtin/pull.c`, `run_fetch`).
57const FETCH_OPTIONS: [&str; 13] = [
58    "--all", "--prune", "-p", "--no-prune", "--tags", "-t", "--no-tags", "--force", "-f", "--ipv4", "-4", "--ipv6", "-6",
59];
60
61/// The options that go to both halves (`argv_push_verbosity`).
62const SHARED_OPTIONS: [&str; 4] = ["-q", "--quiet", "-v", "--verbose"];
63
64/// The settings that make the wrapper leave the pull to native Git: they add steps this does not mirror (a stash around the
65/// pull, the update of submodules after it).
66const NATIVE_ONLY_SETTINGS: [&str; 3] = ["pull.autostash", "submodule.recurse", "fetch.recursesubmodules"];
67
68/// What:
69///  What a pull reads from its command line: the arguments of its fetch, of its merge, and where the choice of merging
70///  was stated.
71/// Why:
72///   The two halves of a pull take different options, and a pull merges only when the command line or the settings say so.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// type PullPlan = { fetch: string[]; merge: string[]; places: string[]; mergeStated: boolean; fastForwardStated: boolean };
77/// ```
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct PullPlan {
80    /// Options for `git fetch`, as typed.
81    pub fetch: Vec<OsString>,
82    /// Options for `git merge`, as typed.
83    pub merge: Vec<OsString>,
84    /// The repository and refspecs, after the options.
85    pub places: Vec<OsString>,
86    /// `--no-rebase` or `--rebase=false` was typed, which overrides every setting.
87    pub merge_stated: bool,
88    /// `--ff` or `--no-ff` was typed, which is a stated way of reconciling divergent branches.
89    pub fast_forward_stated: bool,
90}
91
92/// What:
93///  Read the arguments after `pull`, by exact spelling, or nothing when any of them is not read exactly.
94/// Why:
95///   Git accepts unambiguous abbreviations and options this does not mirror (`--rebase`, `--ff-only`, `-s`, `-X`,
96///  `--autostash`, `--dry-run`, `--recurse-submodules`); a pull with one of them is native Git's whole, and so is a refspec
97///  that could move the checked-out branch (it holds a colon).
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// function planPull(region: string[]): PullPlan | undefined;
102/// ```
103pub fn plan_pull(region: &[OsString]) -> Option<PullPlan> {
104    // The plan being built; `Vec::new()` is an empty list like `[]`.
105    let mut plan: PullPlan = PullPlan { fetch: Vec::new(), merge: Vec::new(), places: Vec::new(), merge_stated: false, fast_forward_stated: false };
106    // `for ... in` walks the tokens in order, like `for (const token of region)`.
107    for token in region {
108        // `?` returns nothing at once when the token is not text, like an early `return undefined`.
109        let text: &str = token.to_str()?;
110        if text == "--no-rebase" || text == "--rebase=false" {
111            plan.merge_stated = true;
112        } else if MERGE_OPTIONS.contains(&text) {
113            plan.fast_forward_stated |= text == "--ff" || text == "--no-ff";
114            plan.merge.push(token.clone());
115        } else if FETCH_OPTIONS.contains(&text) {
116            plan.fetch.push(token.clone());
117        } else if SHARED_OPTIONS.contains(&text) {
118            plan.fetch.push(token.clone());
119            plan.merge.push(token.clone());
120        } else if text.starts_with('-') {
121            // An option this does not read exactly, `--` included.
122            return None;
123        } else if !plan.places.is_empty() && text.contains(':') {
124            // A refspec that names a destination could update the branch that is checked out.
125            return None;
126        } else {
127            plan.places.push(token.clone());
128        }
129    }
130    return Some(plan);
131}
132
133/// What:
134///  Whether a boolean setting says "no", the way Git reads `false`, `no`, `off`, `0` and the empty value.
135/// Why:
136///   `pull.rebase` and `branch.<name>.rebase` are booleans or one of `merges` and `interactive`; only a "no" is a merge.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// const isNo = (value: string) => ['false', 'no', 'off', '0', ''].includes(value.toLowerCase());
141/// ```
142fn says_no(value: &str) -> bool {
143    return matches!(value.to_ascii_lowercase().as_str(), "false" | "no" | "off" | "0" | "");
144}
145
146/// What:
147///  Whether the command line and the settings state that this pull merges and mirror nothing else the pull would do.
148/// Why:
149///   Git's pull merges when `--no-rebase` is typed or `pull.rebase` / `branch.<name>.rebase` say no, or, with neither set, when
150///  a way of reconciling is typed (`--ff`, `--no-ff`); with nothing stated it refuses divergent branches with its advice, and
151///  that refusal stays native Git's. `pull.ff` is left to Git, because with `--no-rebase` it is rewritten by rules of its own.
152///
153/// In TS you'd write (pseudocode):
154/// ```ts
155/// function mergeIsStated(plan: PullPlan, settings: Map<string, string>, branch: string): boolean;
156/// ```
157pub fn merge_is_stated(plan: &PullPlan, settings: &[(String, String)], branch: &str) -> bool {
158    // The settings that add steps this does not mirror.
159    if settings.iter().any(|(key, _)| { return NATIVE_ONLY_SETTINGS.contains(&key.as_str()); }) {
160        return false;
161    }
162    // `pull.ff` is Git's to combine with the rest.
163    if !plan.fast_forward_stated && settings.iter().any(|(key, _)| { return key == "pull.ff"; }) {
164        return false;
165    }
166    // The branch's own setting wins over the general one, as in Git's `config_get_rebase`.
167    let branch_key: String = format!("branch.{branch}.rebase");
168    let chosen: Option<&String> = settings
169        .iter()
170        .find(|(key, _)| { return *key == branch_key; })
171        .or_else(|| { return settings.iter().find(|(key, _)| { return key == "pull.rebase"; }); })
172        .map(|(_, value)| { return value; });
173    if plan.merge_stated {
174        return true;
175    }
176    return match chosen {
177        Some(value) => says_no(value.as_str()),
178        None => plan.fast_forward_stated,
179    };
180}
181
182/// What:
183///  Run a read-only query against the invocation's repository.
184/// Why:
185///   Each query is one Git start; the answer is the output whatever its status, so a caller reads the code.
186///
187/// In TS you'd write (pseudocode):
188/// ```ts
189/// async function ask(context: GitContext, args: string[]): Promise<GitOutput | undefined>;
190/// ```
191fn ask(context: &GitContext, arguments: &[&str]) -> Option<GitOutput> {
192    // `.ok()` turns a failed start into nothing, which the callers treat as "leave the pull to native Git".
193    return run_git(context, &GitRequest::new(Path::new("."), arguments)).ok();
194}
195
196/// What:
197///  The settings that decide whether a pull merges, as key and value pairs.
198/// Why:
199///   One `git config` call reads them all; a value-less key reads as `true` like Git does.
200///
201/// In TS you'd write (pseudocode):
202/// ```ts
203/// async function pullSettings(context): Promise<[string, string][] | undefined>;
204/// ```
205fn pull_settings(context: &GitContext) -> Option<Vec<(String, String)>> {
206    let output: GitOutput = ask(
207        context,
208        &[
209            "config",
210            "--null",
211            "--get-regexp",
212            "^(pull\\.(rebase|ff|autostash)|submodule\\.recurse|fetch\\.recursesubmodules|branch\\..*\\.rebase)$",
213        ],
214    )?;
215    // Status 1 means no setting matched.
216    if output.code != Some(0) && output.code != Some(1) {
217        return None;
218    }
219    // A list of key and value pairs, built entry by entry.
220    let mut settings: Vec<(String, String)> = Vec::new();
221    for entry in output.stdout.split(|byte| { return *byte == 0; }).filter(|entry| { return !entry.is_empty(); }) {
222        let text: String = String::from_utf8_lossy(entry).into_owned();
223        let (key, value): (&str, &str) = text.split_once('\n').unwrap_or((text.as_str(), "true"));
224        settings.push((key.to_owned(), value.to_owned()));
225    }
226    return Some(settings);
227}
228
229/// What:
230///  The first line of a query's output, trimmed, when the query succeeded.
231/// Why:
232///   Names and object names are one line.
233///
234/// In TS you'd write (pseudocode):
235/// ```ts
236/// const line = (output) => output.code === 0 ? output.stdout.trim() : undefined;
237/// ```
238fn line_of(output: Option<GitOutput>) -> Option<String> {
239    let found: GitOutput = output?;
240    if found.code != Some(0) {
241        return None;
242    }
243    return Some(String::from_utf8_lossy(&found.stdout).trim().to_owned());
244}
245
246/// What:
247///  How a pull is handled: native Git's whole, already ended, or the merge that follows its fetch.
248/// Why:
249///   The caller either forwards the pull unchanged, returns the end, or runs the merge forward it is given.
250///
251/// In TS you'd write (pseudocode):
252/// ```ts
253/// type PullStep = { kind: 'native' } | { kind: 'ended'; run: CoordinatedRun } | { kind: 'merge'; forward: CoordinatedForward; makesCommit: boolean };
254/// ```
255#[derive(Clone, Debug, Eq, PartialEq)]
256pub enum PullStep {
257    /// Native Git runs the whole pull.
258    Native,
259    /// The pull ended here (its fetch failed, or the working tree could not follow the fetch).
260    Ended(CoordinatedRun),
261    /// The fetch is done and this merge forward finishes the pull; boxed because a forward is large.
262    Merge {
263        /// The `git merge FETCH_HEAD` that ends the pull.
264        forward: Box<CoordinatedForward>,
265        /// Whether the merge creates a commit, and so is worth the isolated attempt; a fast-forward or a merge of nothing is not.
266        makes_commit: bool,
267    },
268}
269
270/// What:
271///  The merge forward that finishes a pull: `git merge <options> FETCH_HEAD` with the invocation's global options.
272/// Why:
273///   That is the command `run_merge` starts, so the isolated merge, the lock wait and the second pass handle it as they
274///  handle a typed merge.
275///
276/// In TS you'd write (pseudocode):
277/// ```ts
278/// function mergeForward(forward: CoordinatedForward, plan: PullPlan, overlay): CoordinatedForward;
279/// ```
280fn merge_forward(forward: &CoordinatedForward, plan: &PullPlan, overlay: Vec<(OsString, OsString)>) -> CoordinatedForward {
281    // The global options stay in front of the subcommand, as the caller typed them.
282    let mut arguments: Vec<OsString> = forward.command.arguments[..forward.command.subcommand_index].to_vec();
283    arguments.push(OsString::from("merge"));
284    arguments.extend(plan.merge.iter().cloned());
285    arguments.push(OsString::from("FETCH_HEAD"));
286    let command: ResolvedCommand = ResolvedCommand {
287        arguments: arguments.clone(),
288        subcommand: Some(OsString::from("merge")),
289        ..forward.command.clone()
290    };
291    return CoordinatedForward { arguments, command, overlay, ..forward.clone() };
292}
293
294/// What:
295///  Decide what to do with a `git pull`, and run its fetch when the merge half can be completed here; `real_index` is the index
296///  the working tree update waits on.
297/// Why:
298///   Everything before the fetch decides from the command line, the settings and the repository's state, so a pull this
299///  cannot mirror starts nothing; after the fetch, a `FETCH_HEAD` that does not list exactly one head goes back to native
300///  Git's whole pull, which fetches again and words the refusal itself.
301///
302/// In TS you'd write (pseudocode):
303/// ```ts
304/// async function pullStep(forward: CoordinatedForward, realIndex: string): Promise<PullStep>;
305/// ```
306pub fn pull_step(forward: &CoordinatedForward, real_index: &Path) -> PullStep {
307    let Some(plan) = plan_pull(forward.command.region()) else {
308        debug(TAG, "native Git runs this pull: an option or operand is not read exactly");
309        return PullStep::Native;
310    };
311    let (git_dir, root): (&Path, &Path) = match &forward.identity {
312        WorktreeIdentity::MainWorktree { git_dir, worktree_root, .. } | WorktreeIdentity::LinkedWorktree { git_dir, worktree_root, .. } => {
313            (git_dir.as_path(), worktree_root.as_path())
314        }
315        _ => return PullStep::Native,
316    };
317    if IN_PROGRESS_MARKERS.iter().any(|name| { return git_dir.join(name).exists(); }) || root.join(".gitmodules").exists() {
318        debug(TAG, "native Git runs this pull: another operation is in progress, or the repository has submodules");
319        return PullStep::Native;
320    }
321    let context: GitContext = GitContext {
322        real_git: forward.real_git.clone(),
323        overlay: forward.overlay.clone(),
324        global_prefix: forward.command.arguments[..forward.command.subcommand_index].to_vec(),
325        common_dir: None,
326    };
327    let (Some(branch), Some(settings), Some(orig_head)) = (
328        line_of(ask(&context, &["symbolic-ref", "--quiet", "--short", "HEAD"])),
329        pull_settings(&context),
330        line_of(ask(&context, &["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])),
331    ) else {
332        debug(TAG, "native Git runs this pull: no branch is checked out, or its head is unborn");
333        return PullStep::Native;
334    };
335    if !merge_is_stated(&plan, settings.as_slice(), branch.as_str()) {
336        debug(TAG, "native Git runs this pull: it is not stated to merge, or a setting adds steps this does not mirror");
337        return PullStep::Native;
338    }
339    // An unmerged entry makes native Git's pull refuse with its own advice.
340    let unmerged: Option<GitOutput> = ask(&context, &["ls-files", "--unmerged", "-z"]);
341    if unmerged.as_ref().is_none_or(|found| { return found.code != Some(0) || !found.stdout.is_empty(); }) {
342        debug(TAG, "native Git runs this pull: the index holds unmerged entries");
343        return PullStep::Native;
344    }
345    // Git names the pull in the reflog of every ref the pull moves, unless the caller already did.
346    let mut overlay: Vec<(OsString, OsString)> = forward.overlay.clone();
347    if environment_value(forward.environment.as_slice(), "GIT_REFLOG_ACTION").is_none() {
348        let mut action: OsString = OsString::from("pull");
349        for token in forward.command.region() {
350            action.push(" ");
351            action.push(token);
352        }
353        overlay.push((OsString::from("GIT_REFLOG_ACTION"), action));
354    }
355    // `git fetch --update-head-ok <options> <repository> <refspecs>`, as `run_fetch` builds it.
356    let mut fetch: Vec<OsString> = forward.command.arguments[..forward.command.subcommand_index].to_vec();
357    fetch.push(OsString::from("fetch"));
358    fetch.push(OsString::from("--update-head-ok"));
359    fetch.extend(plan.fetch.iter().cloned());
360    fetch.extend(plan.places.iter().cloned());
361    debug(TAG, "the fetch of a pull that merges runs, then the merge goes through the isolated merge");
362    match run_forwarded_git(forward.real_git.as_path(), fetch.as_slice(), overlay.as_slice(), None) {
363        ForwardedRun::Ran(0) => {}
364        // A failed fetch ends the pull with status 1, as `cmd_pull` does.
365        ForwardedRun::Ran(_) => return PullStep::Ended(CoordinatedRun::Ran(1)),
366        ForwardedRun::Failed { stderr, code } => return PullStep::Ended(CoordinatedRun::Failed { stderr, code }),
367    }
368    // The fetch can update the branch that is checked out; Git then moves the working tree along with it before it merges.
369    let Some(curr_head) = line_of(ask(&context, &["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])) else {
370        return PullStep::Native;
371    };
372    if curr_head != orig_head {
373        let _noted = std::io::stderr().write_all(
374            format!("warning: fetch updated the current branch head.\nfast-forwarding your working tree from\ncommit {orig_head}.\n").as_bytes(),
375        );
376        // The update takes `index.lock` like any writer; the pull waits for a holder, bounded and naming it, where Git's step dies.
377        wait_for_service_hold_here(real_index, "pull's merge");
378        wait_for_index_release_here(real_index, "pull's merge");
379        let moved: Option<GitOutput> = ask(&context, &["read-tree", "-u", "-m", orig_head.as_str(), curr_head.as_str()]);
380        if moved.is_none_or(|found| { return found.code != Some(0); }) {
381            return PullStep::Ended(CoordinatedRun::Failed {
382                stderr: format!(
383                    "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"
384                ),
385                code: 128,
386            });
387        }
388    }
389    let fetched: String = std::fs::read_to_string(git_dir.join("FETCH_HEAD")).unwrap_or_default();
390    let heads: Vec<String> = fetch_head_merge_heads(fetched.as_str());
391    if heads.len() != 1 {
392        debug(TAG, "native Git runs the whole pull again: FETCH_HEAD does not list exactly one head to merge");
393        return PullStep::Native;
394    }
395    // One start tells a merge that makes a commit from a fast-forward (the merge base is `HEAD`) and from nothing to merge (the
396    // merge base is the head); only the first is worth the isolated attempt, whose queries the other two would only waste.
397    let bases: Option<GitOutput> = ask(&context, &["merge-base", "--all", "HEAD", heads[0].as_str()]);
398    let makes_commit: bool = bases.is_none_or(|found| {
399        let listed: String = String::from_utf8_lossy(&found.stdout).trim().to_owned();
400        return found.code != Some(0) || (listed != curr_head && listed != heads[0]);
401    });
402    return PullStep::Merge { forward: Box::new(merge_forward(forward, &plan, overlay)), makes_commit };
403}
404
405/// Controls for the pull's reading stay out of the release executable.
406#[cfg(test)]
407#[path = "pull_isolation_tests.rs"]
408mod tests;