Skip to main content

weave_cli/
mergestate.rs

1//! What state a merge is really in, for `land --onto` / `--queue`.
2//!
3//! Two facts land must never guess at:
4//!
5//! * **`index.lock`.** git takes it to write the index and removes it when
6//!   done. A git process that is killed leaves it behind, and every later
7//!   index write (a merge, a commit) fails on it. A lock is stale only when
8//!   no running process has the file open — checked, not assumed, on Linux
9//!   through `/proc/*/fd` and elsewhere through `lsof`; when neither can
10//!   tell, the lock counts as held.
11//! * **Whose merge is in progress.** `MERGE_HEAD` alone does not say that a
12//!   merge was applied: git 2.39 writes `MERGE_HEAD` even when it could not
13//!   write the merge into the index (`index.lock` held), leaving the index
14//!   and the working tree at HEAD. Taken as "the person's resolution", that
15//!   is a merge whose tree is ours — every change only theirs made, gone.
16//!   So land records each merge it starts in `<git-dir>/weave-land-merge`
17//!   (`in-flight` until it is committed; `refused` once land has handed it
18//!   to the person to resolve). A merge in progress that land started and
19//!   never finished is aborted and merged again, never judged as a
20//!   resolution.
21
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25type R<T> = Result<T, Box<dyn std::error::Error>>;
26
27/// Who, if anyone, holds `index.lock`.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Lock {
30    Absent,
31    /// A running process has it open (pids, where known).
32    Held(Vec<u32>),
33    /// The file exists and no running process has it open.
34    Stale,
35    /// The file exists and whether it is held could not be told.
36    Unknown(String),
37}
38
39/// The `index.lock` of the git dir `gd`.
40pub fn index_lock(gd: &Path) -> PathBuf {
41    gd.join("index.lock")
42}
43
44/// Look at `index.lock` in `gd`: three looks 150 ms apart, all of them with
45/// no holder and the same file, before it counts as stale.
46pub fn lock_state(gd: &Path) -> Lock {
47    let path = index_lock(gd);
48    let id = |p: &Path| {
49        std::fs::symlink_metadata(p)
50            .ok()
51            .map(|m| (m.len(), m.modified().ok()))
52    };
53    let Some(first) = id(&path) else {
54        return Lock::Absent;
55    };
56    for look in 0..3 {
57        if look > 0 {
58            std::thread::sleep(Duration::from_millis(150));
59            match id(&path) {
60                None => return Lock::Absent,
61                Some(now) if now != first => return Lock::Held(Vec::new()),
62                Some(_) => {}
63            }
64        }
65        match holders(&path) {
66            Ok(pids) if pids.is_empty() => {}
67            Ok(pids) => return Lock::Held(pids),
68            Err(why) => return Lock::Unknown(why),
69        }
70    }
71    Lock::Stale
72}
73
74/// The pids that have `path` open.
75fn holders(path: &Path) -> Result<Vec<u32>, String> {
76    let want = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
77    if Path::new("/proc/self/fd").is_dir() {
78        let mut pids = Vec::new();
79        let procs = std::fs::read_dir("/proc").map_err(|e| format!("/proc: {e}"))?;
80        for p in procs.flatten() {
81            let Some(pid) = p.file_name().to_str().and_then(|s| s.parse::<u32>().ok()) else {
82                continue;
83            };
84            // A process whose fds cannot be read is not ours to judge; one
85            // that has exited (a zombie) has none.
86            let Ok(fds) = std::fs::read_dir(p.path().join("fd")) else {
87                continue;
88            };
89            for fd in fds.flatten() {
90                if let Ok(target) = std::fs::read_link(fd.path()) {
91                    if target == want || target == path {
92                        pids.push(pid);
93                        break;
94                    }
95                }
96            }
97        }
98        return Ok(pids);
99    }
100    let out = std::process::Command::new("lsof")
101        .arg("-t")
102        .arg("--")
103        .arg(&want)
104        .output()
105        .map_err(|e| format!("could not run lsof to see who holds it: {e}"))?;
106    let text = String::from_utf8_lossy(&out.stdout);
107    let pids: Vec<u32> = text
108        .split_whitespace()
109        .filter_map(|s| s.parse().ok())
110        .collect();
111    match (out.status.code(), pids.is_empty()) {
112        (_, false) => Ok(pids),
113        (Some(1), true) => Ok(Vec::new()),
114        (Some(0), true) => Ok(Vec::new()),
115        _ => Err(format!(
116            "lsof failed: {}",
117            String::from_utf8_lossy(&out.stderr).trim()
118        )),
119    }
120}
121
122/// Make sure no stale `index.lock` stands in the way. `Ok(None)`: there is
123/// none now (a stale one was removed, and that said in `out`). `Ok(Some(why))`:
124/// a lock is held, or its holder could not be told — the caller refuses.
125pub fn clear_stale_lock(gd: &Path, out: &mut dyn std::io::Write) -> R<Option<String>> {
126    let path = index_lock(gd);
127    match lock_state(gd) {
128        Lock::Absent => Ok(None),
129        Lock::Stale => {
130            std::fs::remove_file(&path).or_else(|e| match e.kind() {
131                std::io::ErrorKind::NotFound => Ok(()),
132                _ => Err(e),
133            })?;
134            writeln!(
135                out,
136                "land: removed a stale {} (no running process has it open; a git process that \
137                 was killed left it behind).",
138                path.display()
139            )?;
140            Ok(None)
141        }
142        Lock::Held(pids) => Ok(Some(format!(
143            "{} is held by a running process{}; another git command is using this repository",
144            path.display(),
145            if pids.is_empty() {
146                String::new()
147            } else {
148                format!(
149                    " (pid {})",
150                    pids.iter()
151                        .map(|p| p.to_string())
152                        .collect::<Vec<_>>()
153                        .join(", ")
154                )
155            }
156        ))),
157        Lock::Unknown(why) => Ok(Some(format!(
158            "{} exists and weave cannot tell whether a running process holds it ({why})",
159            path.display()
160        ))),
161    }
162}
163
164/// land's record of the merge it started.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct Record {
167    /// `in-flight` (started, not yet committed or handed over) or `refused`
168    /// (handed to the person to resolve in place).
169    pub state: String,
170    /// HEAD before the merge.
171    pub pre: String,
172    /// The commit merged in.
173    pub tip: String,
174}
175
176fn record_path(gd: &Path) -> PathBuf {
177    gd.join("weave-land-merge")
178}
179
180pub fn read_record(gd: &Path) -> Option<Record> {
181    let text = std::fs::read_to_string(record_path(gd)).ok()?;
182    let f: Vec<&str> = text.split_whitespace().collect();
183    match f.as_slice() {
184        [state, pre, tip] => Some(Record {
185            state: state.to_string(),
186            pre: pre.to_string(),
187            tip: tip.to_string(),
188        }),
189        _ => None,
190    }
191}
192
193pub fn write_record(gd: &Path, state: &str, pre: &str, tip: &str) -> R<()> {
194    let tmp = gd.join("weave-land-merge.tmp");
195    std::fs::write(&tmp, format!("{state} {pre} {tip}\n"))?;
196    std::fs::rename(&tmp, record_path(gd))?;
197    Ok(())
198}
199
200pub fn clear_record(gd: &Path) {
201    let _ = std::fs::remove_file(record_path(gd));
202}
203
204/// Whether git's output from `git merge` says it could not write the merge
205/// (the index, the working tree): such a merge was not applied, whatever
206/// `MERGE_HEAD` says.
207pub fn merge_not_written(stdout: &str, stderr: &str) -> bool {
208    [stdout, stderr].iter().any(|t| {
209        t.contains("index.lock")
210            || t.contains("Unable to write index")
211            || t.contains("unable to write")
212            || t.lines().any(|l| l.starts_with("fatal:"))
213    })
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn a_lock_nobody_has_open_is_stale_and_one_held_open_is_not() {
222        let gd = std::env::temp_dir().join(format!("weave-lock-{}", std::process::id()));
223        std::fs::create_dir_all(&gd).unwrap();
224        assert_eq!(lock_state(&gd), Lock::Absent);
225        std::fs::write(index_lock(&gd), "").unwrap();
226        let held = std::fs::File::open(index_lock(&gd)).unwrap();
227        match lock_state(&gd) {
228            Lock::Held(pids) => assert!(pids.contains(&std::process::id()), "{pids:?}"),
229            Lock::Unknown(_) => {} // no lsof on this machine
230            other => panic!("an open lock is not stale: {other:?}"),
231        }
232        drop(held);
233        match lock_state(&gd) {
234            Lock::Stale | Lock::Unknown(_) => {}
235            other => panic!("{other:?}"),
236        }
237        let _ = std::fs::remove_dir_all(&gd);
238    }
239
240    #[test]
241    fn git_saying_it_could_not_write_the_index_is_not_a_merge() {
242        assert!(merge_not_written(
243            "Automatic merge failed; fix conflicts and then commit the result.\n",
244            "error: Unable to write index.\n"
245        ));
246        assert!(!merge_not_written(
247            "Auto-merging a.go\nCONFLICT (content): Merge conflict in a.go\n",
248            ""
249        ));
250    }
251}