1use std::fs::{self, File, OpenOptions};
19use std::io::ErrorKind;
20use std::path::{Path, PathBuf};
21
22use crate::error::PawError;
23
24pub const LOCK_FILE_NAME: &str = ".add-remove.lock";
26
27#[must_use]
30pub fn lock_path(repo_root: &Path) -> PathBuf {
31 repo_root.join(".git-paw").join(LOCK_FILE_NAME)
32}
33
34#[derive(Debug)]
40pub struct SessionLock {
41 path: PathBuf,
42 _file: File,
45}
46
47impl SessionLock {
48 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 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 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 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}