Skip to main content

git_policy_cli/
conflict_sides.rs

1//! What:
2//!  The decision and the file work behind taking one side of a conflicted file: which operation is in progress, which
3//!  paths the index marks as conflicted, whether a command line names only such paths, and the saved copy of each file
4//!  before the command replaces it.
5//! Why:
6//!  `linked-worktree-only` rejects `git checkout --theirs -- a.rs` because it overwrites the worktree file. During a
7//!  merge, a rebase, a cherry-pick or a revert that command is how a conflict is settled, and the file it overwrites is the
8//!  half-resolved copy a person may have edited by hand. The owner's decision is to let it through for conflicted paths
9//!  after the current file is copied beside the audit log (`discard_audit.rs`), so nothing is lost and the copy is named.
10//!  Everything here is read in process: the operation from the state files in the Git directory, the conflicted paths from
11//!  the unmerged entries of the index file. The copy is written and synced before the caller lets Git run.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const conflicted = unmergedPaths(await readIndex(gitDir));
16//! const decision = decideSides({ pathspecs, prefix, conflicted });
17//! if (decision.kind === 'allow') await saveCopies({ root, commonDir, paths: decision.paths });
18//! ```
19
20use super::iso_time::format_iso_milliseconds;
21use super::private_storage::{create_private_directory, create_private_directory_all, sync_directory};
22use super::repo_read_index::IndexEntry;
23use std::collections::BTreeSet;
24use std::ffi::{OsStr, OsString};
25use std::io::{Read, Write};
26use std::os::unix::ffi::{OsStrExt, OsStringExt};
27use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
28use std::path::{Path, PathBuf};
29
30/// Where the copies live, below the common Git directory, beside `cli-git-audit.jsonl`.
31pub const SAVED_DIRECTORY: &str = "cli-git/discarded";
32
33/// An operation that stops with conflicts and waits for them to be settled.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum Operation {
36    /// `git merge`.
37    Merge,
38    /// `git rebase`, either backend.
39    Rebase,
40    /// `git cherry-pick`.
41    CherryPick,
42    /// `git revert`.
43    Revert,
44}
45
46/// What:
47///  The operation in progress in a worktree's Git directory, if any.
48/// Why:
49///   Git leaves `MERGE_HEAD`, `CHERRY_PICK_HEAD` or `REVERT_HEAD` while a merge, cherry-pick or revert waits, and a
50///  `rebase-merge` directory or a `rebase-apply` directory that holds `rebasing` for a rebase (the same directory with
51///  `applying` is `git am`, whose conflicts are not covered).
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// function operationInProgress(gitDir: string): Operation | undefined;
56/// ```
57pub fn operation_in_progress(git_dir: &Path) -> Option<Operation> {
58    if git_dir.join("MERGE_HEAD").exists() {
59        return Some(Operation::Merge);
60    }
61    if git_dir.join("rebase-merge").is_dir() || git_dir.join("rebase-apply/rebasing").exists() {
62        return Some(Operation::Rebase);
63    }
64    if git_dir.join("CHERRY_PICK_HEAD").exists() {
65        return Some(Operation::CherryPick);
66    }
67    if git_dir.join("REVERT_HEAD").exists() {
68        return Some(Operation::Revert);
69    }
70    return None;
71}
72
73/// What:
74///  The paths with an unmerged entry, once each.
75/// Why:
76///   A conflicted path has entries at stage 1, 2 or 3 in place of the one at stage 0.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// const conflicted = new Set(entries.filter((entry) => entry.stage !== 0).map((entry) => entry.path));
81/// ```
82pub fn conflicted_paths(entries: &[IndexEntry]) -> BTreeSet<Vec<u8>> {
83    return entries.iter().filter(|entry| { return entry.stage != 0; }).map(|entry| { return entry.path.clone(); }).collect();
84}
85
86/// What:
87///  The repository-relative path a pathspec token names, when it names exactly one file by its literal spelling.
88/// Why:
89///   Git resolves a pathspec below the directory the command runs in. Magic (`:(glob)`, `:/`), wildcards, a trailing
90///  slash and a directory are not a single conflicted file, so they resolve to nothing and the usual rejection stays.
91///  `.` and `..` segments are folded; a path that leaves the worktree resolves to nothing.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// function literalPath(prefix: string, root: string, token: string): string | undefined;
96/// ```
97pub fn literal_path(prefix: &[u8], root: &Path, token: &OsStr) -> Option<Vec<u8>> {
98    let bytes: &[u8] = token.as_bytes();
99    if bytes.is_empty() || bytes[0] == b':' || bytes.ends_with(b"/") || bytes.iter().any(|byte| { return matches!(byte, b'*' | b'?' | b'[' | b'\\'); }) {
100        return None;
101    }
102    let joined: Vec<u8> = if bytes[0] == b'/' {
103        let below: &Path = Path::new(token).strip_prefix(root).ok()?;
104        below.as_os_str().as_bytes().to_vec()
105    } else {
106        [prefix, bytes].concat()
107    };
108    let mut kept: Vec<&[u8]> = Vec::new();
109    for segment in joined.split(|byte| { return *byte == b'/'; }) {
110        match segment {
111            b"" | b"." => {}
112            b".." => {
113                kept.pop()?;
114            }
115            other => kept.push(other),
116        }
117    }
118    if kept.is_empty() {
119        return None;
120    }
121    return Some(kept.join(&b'/'));
122}
123
124/// What a command line that names paths came to, against the conflicted set.
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub enum SideDecision {
127    /// Every path is a conflicted file: the repository-relative paths, in the order named, each once.
128    Allow(Vec<Vec<u8>>),
129    /// At least one token is not a conflicted file; these are the tokens, as typed.
130    Refuse(Vec<OsString>),
131}
132
133/// What:
134///  Whether every token names a conflicted file.
135/// Why:
136///   A pathspec that mixes conflicted and clean paths is refused whole, and the refusal names the clean ones, so a
137///  person sees exactly what to leave out.
138///
139/// In TS you'd write (pseudocode):
140/// ```ts
141/// function decideSides(tokens: string[], prefix: string, root: string, conflicted: Set<string>): SideDecision;
142/// ```
143pub fn decide_sides(tokens: &[OsString], prefix: &[u8], root: &Path, conflicted: &BTreeSet<Vec<u8>>) -> SideDecision {
144    let mut allowed: Vec<Vec<u8>> = Vec::new();
145    let mut others: Vec<OsString> = Vec::new();
146    for token in tokens {
147        match literal_path(prefix, root, token.as_os_str()) {
148            Some(path) if conflicted.contains(&path) => {
149                if !allowed.contains(&path) {
150                    allowed.push(path);
151                }
152            }
153            _ => others.push(token.clone()),
154        }
155    }
156    if others.is_empty() {
157        return SideDecision::Allow(allowed);
158    }
159    return SideDecision::Refuse(others);
160}
161
162/// Why a copy could not be written.
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct SaveFailure {
165    /// The repository-relative path being copied, or the place the copies go when none was reached.
166    pub subject: String,
167    /// The operating system's words.
168    pub reason: String,
169}
170
171/// One saved file.
172#[derive(Clone, Debug, Eq, PartialEq)]
173pub struct SavedCopy {
174    /// The repository-relative path.
175    pub path: String,
176    /// Where the copy is, or nothing when the path is deleted in the worktree and has nothing to copy.
177    pub saved_to: Option<PathBuf>,
178}
179
180/// The name of a directory for copies made at `milliseconds`: `2026-11-02T14-01-07`.
181fn stamp(milliseconds: i64) -> String {
182    return format_iso_milliseconds(milliseconds).chars().take(19).map(|each| { return if each == ':' { '-' } else { each }; }).collect();
183}
184
185/// Create the directory for this invocation's copies, never one that exists: a second invocation in the same second
186///  gets a numbered sibling, so no copy overwrites another.
187fn fresh_directory(saved_root: &Path, name: &str) -> std::io::Result<PathBuf> {
188    create_private_directory_all(saved_root)?;
189    for number in 0_u32..1000 {
190        let candidate: PathBuf = if number == 0 { saved_root.join(name) } else { saved_root.join(format!("{name}-{number}")) };
191        match create_private_directory(candidate.as_path()) {
192            Ok(()) => return Ok(candidate),
193            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
194            Err(error) => return Err(error),
195        }
196    }
197    return Err(std::io::Error::other("a thousand copies were saved in this second"));
198}
199
200/// Copy one file's content, mode and kind to `destination`, which must not exist, and sync the file.
201fn copy_one(source: &Path, destination: &Path) -> std::io::Result<()> {
202    let metadata: std::fs::Metadata = std::fs::symlink_metadata(source)?;
203    if metadata.file_type().is_symlink() {
204        std::os::unix::fs::symlink(std::fs::read_link(source)?, destination)?;
205        return Ok(());
206    }
207    if !metadata.file_type().is_file() {
208        return Err(std::io::Error::other("it is neither a file nor a symbolic link"));
209    }
210    let mut from: std::fs::File = std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW).open(source)?;
211    let mut to: std::fs::File = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(destination)?;
212    let written: std::io::Result<()> = (|| -> std::io::Result<()> {
213        let mut buffer: Vec<u8> = vec![0; 1 << 16];
214        loop {
215            let count: usize = from.read(buffer.as_mut_slice())?;
216            if count == 0 {
217                break;
218            }
219            to.write_all(&buffer[..count])?;
220        }
221        to.set_permissions(std::fs::Permissions::from_mode_bits(metadata.mode() & 0o777))?;
222        return to.sync_all();
223    })();
224    if let Err(error) = written {
225        // A cut-short file named like a copy would pass for the whole file; the command is rejected, so none is left.
226        let _removed = std::fs::remove_file(destination);
227        return Err(error);
228    }
229    return Ok(());
230}
231
232/// `Permissions` from raw mode bits, named for what the call does.
233trait FromModeBits {
234    /// The permissions with exactly these bits.
235    fn from_mode_bits(bits: u32) -> Self;
236}
237
238impl FromModeBits for std::fs::Permissions {
239    fn from_mode_bits(bits: u32) -> Self {
240        return std::os::unix::fs::PermissionsExt::from_mode(bits);
241    }
242}
243
244/// What:
245///  Save the current worktree file of each path below the common Git directory and sync everything written.
246/// Why:
247///   The command that follows overwrites these files, so each is copied first with its content, its permission bits or
248///  its symbolic-link target. A path with no file in the worktree has nothing to copy and is reported as such. The
249///  first failure stops the work and is returned, so the caller refuses the command instead of discarding unsaved work.
250///
251/// In TS you'd write (pseudocode):
252/// ```ts
253/// const copies = await saveCopies({ root, commonDir, paths, milliseconds });
254/// ```
255pub fn save_copies(root: &Path, common_dir: &Path, paths: &[Vec<u8>], milliseconds: i64) -> Result<Vec<SavedCopy>, SaveFailure> {
256    let saved_root: PathBuf = common_dir.join(SAVED_DIRECTORY);
257    let mut copies: Vec<SavedCopy> = Vec::new();
258    let mut directory: Option<PathBuf> = None;
259    let mut written_directories: BTreeSet<PathBuf> = BTreeSet::new();
260    for path in paths {
261        let shown: String = String::from_utf8_lossy(path).into_owned();
262        let source: PathBuf = root.join(OsString::from_vec(path.clone()));
263        match std::fs::symlink_metadata(source.as_path()) {
264            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
265                copies.push(SavedCopy { path: shown, saved_to: None });
266                continue;
267            }
268            Err(error) => return Err(SaveFailure { subject: shown, reason: error.to_string() }),
269            Ok(_) => {}
270        }
271        let base: PathBuf = match &directory {
272            Some(existing) => existing.clone(),
273            None => {
274                let created: PathBuf = fresh_directory(saved_root.as_path(), stamp(milliseconds).as_str())
275                    .map_err(|error| { return SaveFailure { subject: saved_root.display().to_string(), reason: error.to_string() }; })?;
276                directory = Some(created.clone());
277                created
278            }
279        };
280        let destination: PathBuf = base.join(OsString::from_vec(path.clone()));
281        let failed = |error: std::io::Error| -> SaveFailure { return SaveFailure { subject: shown.clone(), reason: error.to_string() }; };
282        if let Some(parent) = destination.parent() {
283            create_private_directory_all(parent).map_err(failed)?;
284            let mut up: Option<&Path> = Some(parent);
285            while let Some(each) = up {
286                written_directories.insert(each.to_path_buf());
287                if each == base.as_path() {
288                    break;
289                }
290                up = each.parent();
291            }
292        }
293        copy_one(source.as_path(), destination.as_path()).map_err(failed)?;
294        copies.push(SavedCopy { path: shown, saved_to: Some(destination) });
295    }
296    if directory.is_some() {
297        // Deepest first, then the directories that hold the copies' root, so every new entry is durable.
298        let mut ordered: Vec<&PathBuf> = written_directories.iter().collect();
299        ordered.sort_by_key(|each| { return std::cmp::Reverse(each.components().count()); });
300        for each in ordered {
301            sync_directory(each.as_path()).map_err(|error| { return SaveFailure { subject: each.display().to_string(), reason: error.to_string() }; })?;
302        }
303        let parent: PathBuf = common_dir.join("cli-git");
304        for each in [saved_root.as_path(), parent.as_path()] {
305            sync_directory(each).map_err(|error| { return SaveFailure { subject: each.display().to_string(), reason: error.to_string() }; })?;
306        }
307    }
308    return Ok(copies);
309}
310
311/// What:
312///  The plain-language notice for the copies, one block per path.
313/// Why:
314///   The person is told where each copy is before Git runs, so a file they edited by hand can be found again.
315///
316/// In TS you'd write (pseudocode):
317/// ```ts
318/// function savedNotice(copies: SavedCopy[]): string;
319/// ```
320pub fn saved_notice(copies: &[SavedCopy]) -> String {
321    let mut text: String = String::new();
322    for copy in copies {
323        match &copy.saved_to {
324            Some(place) => text.push_str(
325                format!(
326                    "cli-git: saved your copy of {} to\n  {}\n  before this command replaces it ({} was conflicted).\n",
327                    copy.path,
328                    place.display(),
329                    copy.path
330                )
331                .as_str(),
332            ),
333            None => text.push_str(
334                format!(
335                    "cli-git: {} was conflicted and is deleted in your worktree, so there is no copy to save; this command brings a file back.\n",
336                    copy.path
337                )
338                .as_str(),
339            ),
340        }
341    }
342    return text;
343}
344
345/// What:
346///  The sentence a rejection gets when the command qualified for a conflict side but some paths were not conflicted.
347/// Why:
348///   The person is told what is allowed and which paths to leave out.
349///
350/// In TS you'd write (pseudocode):
351/// ```ts
352/// function cleanPathsNote(others: string[], operation: Operation): string;
353/// ```
354pub fn clean_paths_note(others: &[OsString], operation: Operation) -> String {
355    let named: Vec<String> = others.iter().map(|each| { return format!("`{}`", each.to_string_lossy()); }).collect();
356    let word: &str = match operation {
357        Operation::Merge => "merge",
358        Operation::Rebase => "rebase",
359        Operation::CherryPick => "cherry-pick",
360        Operation::Revert => "revert",
361    };
362    return format!(
363        " This {word} is in progress, and a command that names only conflicted files is let through after your copy is saved, \
364         but these are not conflicted files: {}. Leave them out, or name each conflicted file itself (no directory, wildcard or \
365         pathspec magic).",
366        named.join(", ")
367    );
368}
369
370/// The decision, the literal paths, the copies and the notices.
371#[cfg(test)]
372#[path = "conflict_sides_tests.rs"]
373mod tests;