diff --git a/Cargo.toml b/Cargo.toml index 8105ee7..8890f35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,6 @@ ratatui = "0.30.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" uuid = { version = "1.22.0", features = ["v4", "serde"] } + +[dev-dependencies] +tempfile = "3" diff --git a/src/core/commit.rs b/src/core/commit.rs index 5a7a7b1..6482407 100644 --- a/src/core/commit.rs +++ b/src/core/commit.rs @@ -3,6 +3,7 @@ use std::process::{Command, ExitStatus}; use crate::core::git::{self, RepoContext}; use crate::core::restack::{self, RestackPreview}; +use crate::core::store::lock::StoreLock; use crate::core::store::{ BranchDivergenceState, PendingCommitEntry, PendingCommitOperation, PendingOperationKind, PendingOperationState, StoreSession, dagger_paths, load_config, load_state, open_initialized, @@ -299,6 +300,7 @@ fn maybe_restack_after_commit_inner( return Ok(PostCommitRestackOutcome::default()); } + let lock = StoreLock::acquire(&store_paths.root)?; let state = load_state(&store_paths)?; let Some(node) = state.find_branch_by_name(current_branch).cloned() else { return Ok(PostCommitRestackOutcome::default()); @@ -306,12 +308,7 @@ fn maybe_restack_after_commit_inner( let actions = restack::plan_after_branch_advance(&state, node.id, &node.branch_name, old_head_oid)?; - let mut session = StoreSession { - repo: context.repo.clone(), - paths: store_paths, - config, - state, - }; + let mut session = StoreSession::from_lock(context.repo.clone(), store_paths, config, state, lock); record_branch_divergence_state(&mut session, node.id, BranchDivergenceState::Diverged)?; let restack_outcome = match workflow::execute_resumable_restack_operation( &mut session, diff --git a/src/core/store/lock.rs b/src/core/store/lock.rs new file mode 100644 index 0000000..3fc1603 --- /dev/null +++ b/src/core/store/lock.rs @@ -0,0 +1,83 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// An advisory lock file that prevents concurrent dagger operations. +/// The lock is released (file deleted) when the guard is dropped. +pub struct StoreLock { + lock_path: PathBuf, +} + +impl StoreLock { + /// Acquire an advisory lock by creating a lock file. + /// Returns an error if another process holds the lock. + pub fn acquire(dagger_root: &Path) -> io::Result { + fs::create_dir_all(dagger_root)?; + let lock_path = dagger_root.join("lock"); + + // Try to create the lock file exclusively. + // O_CREAT | O_EXCL ensures atomic creation — fails if file already exists. + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + { + Ok(mut file) => { + use std::io::Write; + let _ = writeln!(file, "{}", std::process::id()); + Ok(Self { lock_path }) + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(io::Error::other(format!( + "another dgr process appears to be running; \ + if this is incorrect, delete '{}'", + lock_path.display() + ))), + Err(e) => Err(e), + } + } +} + +impl Drop for StoreLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.lock_path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn acquire_creates_lock_file() { + let dir = tempfile::tempdir().unwrap(); + let lock = StoreLock::acquire(dir.path()).unwrap(); + assert!(dir.path().join("lock").exists()); + drop(lock); + } + + #[test] + fn drop_removes_lock_file() { + let dir = tempfile::tempdir().unwrap(); + let lock = StoreLock::acquire(dir.path()).unwrap(); + drop(lock); + assert!(!dir.path().join("lock").exists()); + } + + #[test] + fn second_acquire_fails_while_held() { + let dir = tempfile::tempdir().unwrap(); + let _lock = StoreLock::acquire(dir.path()).unwrap(); + let result = StoreLock::acquire(dir.path()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("another dgr process")); + } + + #[test] + fn acquire_succeeds_after_release() { + let dir = tempfile::tempdir().unwrap(); + let lock = StoreLock::acquire(dir.path()).unwrap(); + drop(lock); + let _lock2 = StoreLock::acquire(dir.path()).unwrap(); + } +} diff --git a/src/core/store/mod.rs b/src/core/store/mod.rs index 3a40e50..3ed48f9 100644 --- a/src/core/store/mod.rs +++ b/src/core/store/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod bootstrap; pub(crate) mod config; pub(crate) mod events; pub(crate) mod fs; +pub(crate) mod lock; pub(crate) mod mutations; pub(crate) mod operation; pub(crate) mod session; diff --git a/src/core/store/session.rs b/src/core/store/session.rs index 736546b..dafc9ea 100644 --- a/src/core/store/session.rs +++ b/src/core/store/session.rs @@ -2,24 +2,56 @@ use std::io; use crate::core::git::{self, RepoContext}; +use super::lock::StoreLock; use super::{ DaggerConfig, StoreInitialization, dagger_paths, initialize_store, load_config, load_state, }; use crate::core::store::fs::DaggerPaths; use crate::core::store::types::DaggerState; -#[derive(Debug, Clone)] pub struct StoreSession { pub repo: RepoContext, pub paths: DaggerPaths, pub config: DaggerConfig, pub state: DaggerState, + _lock: StoreLock, +} + +impl StoreSession { + /// Build a session from pre-loaded parts with an already-acquired lock. + pub fn from_lock( + repo: RepoContext, + paths: DaggerPaths, + config: DaggerConfig, + state: DaggerState, + lock: StoreLock, + ) -> Self { + Self { + repo, + paths, + config, + state, + _lock: lock, + } + } + + /// Build a session from pre-loaded parts, acquiring the lock. + pub fn from_parts( + repo: RepoContext, + paths: DaggerPaths, + config: DaggerConfig, + state: DaggerState, + ) -> io::Result { + let lock = StoreLock::acquire(&paths.root)?; + Ok(Self::from_lock(repo, paths, config, state, lock)) + } } pub fn open_initialized(missing_message: &str) -> io::Result { let repo = git::resolve_repo_context()?; let paths = dagger_paths(&repo.git_dir); let config = load_config(&paths)?.ok_or_else(|| io::Error::other(missing_message))?; + let lock = StoreLock::acquire(&paths.root)?; let state = load_state(&paths)?; Ok(StoreSession { @@ -27,12 +59,14 @@ pub fn open_initialized(missing_message: &str) -> io::Result { paths, config, state, + _lock: lock, }) } pub fn open_or_initialize(trunk_branch: &str) -> io::Result<(StoreSession, StoreInitialization)> { let repo = git::resolve_repo_context()?; let paths = dagger_paths(&repo.git_dir); + let lock = StoreLock::acquire(&paths.root)?; let store_initialization = initialize_store(&paths, trunk_branch)?; let config = load_config(&paths)?.ok_or_else(|| io::Error::other("dagger config is missing"))?; @@ -44,6 +78,7 @@ pub fn open_or_initialize(trunk_branch: &str) -> io::Result<(StoreSession, Store paths, config, state, + _lock: lock, }, store_initialization, ))