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;