Skip to main content

git_paw/
lock.rs

1//! Advisory lock for mutating a live session's branch set.
2//!
3//! `git paw add` and `git paw remove` both splice panes into / out of a
4//! running tmux session and rewrite the session JSON. Two of these running
5//! concurrently — or one racing a supervisor sweep that is itself sending
6//! keys to panes — can corrupt the grid or the session receipt (design
7//! "Risks / Trade-offs: Race: add/remove while a sweep is mutating panes").
8//!
9//! The mitigation is a single advisory lock file under the repo's `.git-paw/`
10//! directory, taken by both subcommands. It is *advisory*: it guards git-paw's
11//! own `add`/`remove` invocations against each other, not arbitrary external
12//! tmux activity. Acquisition is an atomic `create_new` — the first caller
13//! wins, a second concurrent caller gets a clear "operation in progress"
14//! error rather than interleaving its mutations. The lock is released (file
15//! removed) when the [`SessionLock`] guard drops, including on early-return
16//! error paths.
17
18use std::fs::{self, File, OpenOptions};
19use std::io::ErrorKind;
20use std::path::{Path, PathBuf};
21
22use crate::error::PawError;
23
24/// File name of the add/remove advisory lock within `<repo>/.git-paw/`.
25pub const LOCK_FILE_NAME: &str = ".add-remove.lock";
26
27/// Returns the advisory lock path for a repository:
28/// `<repo>/.git-paw/.add-remove.lock`.
29#[must_use]
30pub fn lock_path(repo_root: &Path) -> PathBuf {
31    repo_root.join(".git-paw").join(LOCK_FILE_NAME)
32}
33
34/// RAII guard for the add/remove advisory lock.
35///
36/// Acquired with [`SessionLock::acquire`]; the lock file is removed when the
37/// guard drops. Hold it for the entire mutate-the-session critical section of
38/// `cmd_add` / `cmd_remove`.
39#[derive(Debug)]
40pub struct SessionLock {
41    path: PathBuf,
42    // Held only so the underlying handle lives as long as the guard; the file
43    // is removed on drop via `path`.
44    _file: File,
45}
46
47impl SessionLock {
48    /// Attempts to acquire the advisory lock for `repo_root`.
49    ///
50    /// Creates `<repo>/.git-paw/` if needed, then atomically creates the lock
51    /// file. Returns [`PawError::SessionError`] with an actionable
52    /// "operation in progress" message when the lock is already held (the
53    /// file already exists) — the second concurrent `add`/`remove` SHALL see
54    /// this rather than proceed.
55    pub fn acquire(repo_root: &Path) -> Result<Self, PawError> {
56        let path = lock_path(repo_root);
57        if let Some(parent) = path.parent() {
58            fs::create_dir_all(parent).map_err(|e| {
59                PawError::SessionError(format!(
60                    "failed to create lock directory {}: {e}",
61                    parent.display()
62                ))
63            })?;
64        }
65
66        match OpenOptions::new().write(true).create_new(true).open(&path) {
67            Ok(file) => Ok(Self { path, _file: file }),
68            Err(e) if e.kind() == ErrorKind::AlreadyExists => Err(PawError::SessionError(format!(
69                "another `git paw add` / `git paw remove` operation is in progress for this \
70                 repository.\n\
71                 \n\
72                 Wait for it to finish, then retry. If no such command is running, a previous \
73                 invocation crashed mid-operation — remove the stale lock and retry:\n  \
74                 rm {}",
75                path.display()
76            ))),
77            Err(e) => Err(PawError::SessionError(format!(
78                "failed to acquire session lock {}: {e}",
79                path.display()
80            ))),
81        }
82    }
83}
84
85impl Drop for SessionLock {
86    fn drop(&mut self) {
87        // Best-effort release; a leftover lock surfaces the actionable
88        // "remove the stale lock" hint on the next acquire.
89        let _ = fs::remove_file(&self.path);
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use tempfile::TempDir;
97
98    #[test]
99    fn lock_path_is_under_git_paw_dir() {
100        let repo = TempDir::new().unwrap();
101        let p = lock_path(repo.path());
102        assert_eq!(p, repo.path().join(".git-paw").join(".add-remove.lock"));
103    }
104
105    #[test]
106    fn acquire_creates_the_lock_file() {
107        let repo = TempDir::new().unwrap();
108        let _guard = SessionLock::acquire(repo.path()).expect("first acquire should succeed");
109        assert!(
110            lock_path(repo.path()).exists(),
111            "lock file should exist while the guard is held"
112        );
113    }
114
115    #[test]
116    fn second_concurrent_acquire_errors_with_in_progress_message() {
117        let repo = TempDir::new().unwrap();
118        let _guard = SessionLock::acquire(repo.path()).expect("first acquire should succeed");
119
120        let err = SessionLock::acquire(repo.path())
121            .expect_err("second concurrent acquire must fail while the first is held");
122        let msg = err.to_string();
123        assert!(
124            msg.contains("in progress"),
125            "second acquire should report an operation in progress; got: {msg}"
126        );
127        assert!(
128            msg.contains(".add-remove.lock"),
129            "error should name the lock file so a stale lock can be removed; got: {msg}"
130        );
131    }
132
133    #[test]
134    fn lock_is_released_on_drop_allowing_reacquire() {
135        let repo = TempDir::new().unwrap();
136        {
137            let _guard = SessionLock::acquire(repo.path()).expect("acquire");
138        }
139        assert!(
140            !lock_path(repo.path()).exists(),
141            "lock file should be removed when the guard drops"
142        );
143        // A fresh acquire after release must succeed — serialized, not blocked.
144        let _again = SessionLock::acquire(repo.path())
145            .expect("re-acquire after the previous guard dropped should succeed");
146    }
147
148    #[test]
149    fn acquire_creates_git_paw_dir_when_absent() {
150        let repo = TempDir::new().unwrap();
151        // No .git-paw/ yet.
152        assert!(!repo.path().join(".git-paw").exists());
153        let _guard = SessionLock::acquire(repo.path()).expect("acquire should create .git-paw/");
154        assert!(repo.path().join(".git-paw").is_dir());
155    }
156}