Skip to main content

libgrite_cli/
context.rs

1use std::path::{Path, PathBuf};
2
3use crate::types::ResolveOptions;
4use git2::Repository;
5use libgrite_core::{
6    config::{
7        actor_dir, list_actors, load_actor_config, load_repo_config, load_signing_key,
8        repo_sled_path, save_actor_config, save_repo_config, RepoConfig,
9    },
10    lock::{LockCheckResult, LockPolicy},
11    signing::SigningKeyPair,
12    types::actor::ActorConfig,
13    types::event::Event,
14    types::ids::{generate_actor_id, id_to_hex},
15    GriteError, GriteStore, LockedStore,
16};
17use libgrite_git::{GitError, LockManager, SnapshotManager, SyncManager, WalManager};
18use libgrite_ipc::{DaemonLock, IpcClient};
19
20/// Source of actor selection
21#[derive(Debug, Clone, Copy)]
22pub enum ActorSource {
23    DataDir,
24    Flag,
25    RepoDefault,
26    Auto,
27}
28
29impl ActorSource {
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            ActorSource::DataDir => "env",
33            ActorSource::Flag => "flag",
34            ActorSource::RepoDefault => "repo_default",
35            ActorSource::Auto => "auto",
36        }
37    }
38}
39
40/// Execution mode for commands
41pub enum ExecutionMode {
42    /// Execute locally (no daemon or daemon skipped)
43    Local,
44    /// Route through daemon via IPC
45    Daemon { client: IpcClient, endpoint: String },
46    /// Daemon lock is valid but IPC unreachable
47    Blocked { lock: DaemonLock },
48}
49
50impl std::fmt::Debug for ExecutionMode {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            ExecutionMode::Local => write!(f, "Local"),
54            ExecutionMode::Daemon { endpoint, .. } => {
55                write!(f, "Daemon {{ endpoint: {} }}", endpoint)
56            }
57            ExecutionMode::Blocked { lock } => {
58                write!(
59                    f,
60                    "Blocked {{ pid: {}, expires_in: {}ms }}",
61                    lock.pid,
62                    lock.time_remaining_ms()
63                )
64            }
65        }
66    }
67}
68
69/// Resolved context for a grite command
70pub struct GriteContext {
71    pub git_dir: PathBuf,
72    pub actor_id: String,
73    pub actor_config: ActorConfig,
74    pub data_dir: PathBuf,
75    pub source: ActorSource,
76}
77
78impl Clone for GriteContext {
79    fn clone(&self) -> Self {
80        Self {
81            git_dir: self.git_dir.clone(),
82            actor_id: self.actor_id.clone(),
83            actor_config: self.actor_config.clone(),
84            data_dir: self.data_dir.clone(),
85            source: self.source,
86        }
87    }
88}
89
90impl GriteContext {
91    /// Find the shared git directory (commondir) for this repository.
92    pub fn find_git_dir() -> Result<PathBuf, GriteError> {
93        Self::find_git_dir_at(std::env::current_dir()?)
94    }
95
96    pub fn find_git_dir_at(path: impl AsRef<Path>) -> Result<PathBuf, GriteError> {
97        let repo = Repository::discover(path.as_ref()).map_err(|_| {
98            GriteError::NotFound("Not a git repository (or any parent)".to_string())
99        })?;
100
101        Ok(repo.commondir().to_path_buf())
102    }
103
104    /// Check if we're currently in a git worktree (not the main repo).
105    #[cfg(test)]
106    pub fn is_worktree() -> Result<bool, GriteError> {
107        Self::is_worktree_at(std::env::current_dir()?)
108    }
109
110    #[cfg(test)]
111    pub fn is_worktree_at(path: impl AsRef<Path>) -> Result<bool, GriteError> {
112        let repo = Repository::discover(path.as_ref()).map_err(|_| {
113            GriteError::NotFound("Not a git repository (or any parent)".to_string())
114        })?;
115
116        Ok(repo.path() != repo.commondir())
117    }
118
119    /// Resolve the actor context from options.
120    pub fn resolve(opts: &ResolveOptions) -> Result<Self, GriteError> {
121        let git_dir = Self::find_git_dir()?;
122
123        // 1. Check --data-dir or GRITE_HOME
124        if let Some(ref data_dir) = opts.data_dir {
125            let config = load_actor_config(data_dir)?;
126            return Ok(Self {
127                git_dir,
128                actor_id: config.actor_id.clone(),
129                actor_config: config,
130                data_dir: data_dir.clone(),
131                source: ActorSource::DataDir,
132            });
133        }
134
135        if let Ok(grit_home) = std::env::var("GRITE_HOME") {
136            let data_dir = PathBuf::from(grit_home);
137            let config = load_actor_config(&data_dir)?;
138            return Ok(Self {
139                git_dir,
140                actor_id: config.actor_id.clone(),
141                actor_config: config,
142                data_dir,
143                source: ActorSource::DataDir,
144            });
145        }
146
147        // 2. Check --actor flag
148        if let Some(ref actor_id) = opts.actor {
149            let data_dir = actor_dir(&git_dir, actor_id);
150            let config = load_actor_config(&data_dir)?;
151            return Ok(Self {
152                git_dir,
153                actor_id: config.actor_id.clone(),
154                actor_config: config,
155                data_dir,
156                source: ActorSource::Flag,
157            });
158        }
159
160        // 3. Check repo default
161        if let Some(repo_config) = load_repo_config(&git_dir)? {
162            if let Some(ref default_actor) = repo_config.default_actor {
163                let data_dir = actor_dir(&git_dir, default_actor);
164                if let Ok(config) = load_actor_config(&data_dir) {
165                    return Ok(Self {
166                        git_dir,
167                        actor_id: config.actor_id.clone(),
168                        actor_config: config,
169                        data_dir,
170                        source: ActorSource::RepoDefault,
171                    });
172                }
173            }
174        }
175
176        // 4. Check if any actors exist
177        let actors = list_actors(&git_dir)?;
178        if let Some(first_actor) = actors.first() {
179            let data_dir = actor_dir(&git_dir, &first_actor.actor_id);
180            return Ok(Self {
181                git_dir,
182                actor_id: first_actor.actor_id.clone(),
183                actor_config: first_actor.clone(),
184                data_dir,
185                source: ActorSource::Auto,
186            });
187        }
188
189        // No actors exist - auto-init
190        let actor_id = generate_actor_id();
191        let actor_id_hex = id_to_hex(&actor_id);
192        let data_dir = actor_dir(&git_dir, &actor_id_hex);
193        let config = ActorConfig::new(actor_id, None);
194
195        // Create actor directory and config
196        save_actor_config(&data_dir, &config)?;
197
198        // Set as repo default
199        let repo_config = RepoConfig {
200            default_actor: Some(actor_id_hex.clone()),
201            ..Default::default()
202        };
203        save_repo_config(&git_dir, &repo_config)?;
204
205        Ok(Self {
206            git_dir,
207            actor_id: actor_id_hex,
208            actor_config: config,
209            data_dir,
210            source: ActorSource::Auto,
211        })
212    }
213
214    /// Open the store for this context with exclusive filesystem lock.
215    pub fn open_store(&self) -> Result<LockedStore, GriteError> {
216        GriteStore::open_locked(&repo_sled_path(&self.git_dir))
217    }
218
219    /// Get the sled database path
220    pub fn sled_path(&self) -> PathBuf {
221        repo_sled_path(&self.git_dir)
222    }
223
224    /// Open the WAL manager
225    pub fn open_wal(&self) -> Result<WalManager, GitError> {
226        WalManager::open(&self.git_dir)
227    }
228
229    /// Open the snapshot manager
230    pub fn open_snapshot(&self) -> Result<SnapshotManager, GitError> {
231        SnapshotManager::open(&self.git_dir)
232    }
233
234    /// Open the sync manager
235    pub fn open_sync(&self) -> Result<SyncManager, GitError> {
236        SyncManager::open(&self.git_dir)
237    }
238
239    /// Open the lock manager
240    pub fn open_lock_manager(&self) -> Result<LockManager, GitError> {
241        LockManager::open(&self.git_dir)
242    }
243
244    /// Get the lock policy from repo config
245    pub fn get_lock_policy(&self) -> LockPolicy {
246        load_repo_config(&self.git_dir)
247            .ok()
248            .flatten()
249            .map(|c| c.get_lock_policy())
250            .unwrap_or(LockPolicy::Warn)
251    }
252
253    /// Check locks for a resource before a write operation
254    pub fn check_lock(&self, resource: &str) -> Result<LockCheckResult, GriteError> {
255        let policy = self.get_lock_policy();
256        if policy == LockPolicy::Off {
257            return Ok(LockCheckResult::Clear);
258        }
259
260        let lock_manager = self.open_lock_manager()?;
261
262        let result = lock_manager.check_conflicts(resource, &self.actor_id, policy)?;
263
264        if let LockCheckResult::Blocked(ref conflicts) = result {
265            let conflict_desc: Vec<String> = conflicts
266                .iter()
267                .map(|l| {
268                    format!(
269                        "{} (owned by {}, expires in {}s)",
270                        l.resource,
271                        l.owner,
272                        l.time_remaining_ms() / 1000
273                    )
274                })
275                .collect();
276            return Err(GriteError::Conflict(format!(
277                "Blocked by lock policy: {}",
278                conflict_desc.join(", ")
279            )));
280        }
281
282        Ok(result)
283    }
284
285    /// Get the repository root path
286    pub fn repo_root(&self) -> PathBuf {
287        self.git_dir.parent().unwrap_or(&self.git_dir).to_path_buf()
288    }
289
290    /// Load the signing key pair for this actor (if available)
291    pub fn load_signing_key(&self) -> Option<SigningKeyPair> {
292        load_signing_key(&self.git_dir, &self.actor_id)
293            .and_then(|seed_hex| SigningKeyPair::from_seed_hex(&seed_hex).ok())
294    }
295
296    /// Sign an event if a signing key is available
297    pub fn sign_event(&self, mut event: Event) -> Event {
298        if let Some(keypair) = self.load_signing_key() {
299            event.sig = Some(keypair.sign_event(&event));
300        }
301        event
302    }
303
304    /// Determine execution mode (local vs daemon)
305    pub fn execution_mode(&self, no_daemon: bool) -> ExecutionMode {
306        if no_daemon {
307            return ExecutionMode::Local;
308        }
309
310        match DaemonLock::read(&self.git_dir.join("grite")) {
311            Ok(Some(lock)) => {
312                if lock.is_expired() {
313                    return ExecutionMode::Local;
314                }
315
316                match IpcClient::connect(&lock.ipc_endpoint) {
317                    Ok(client) => ExecutionMode::Daemon {
318                        endpoint: lock.ipc_endpoint.clone(),
319                        client,
320                    },
321                    Err(_) => ExecutionMode::Blocked { lock },
322                }
323            }
324            Ok(None) => ExecutionMode::Local,
325            Err(_) => ExecutionMode::Local,
326        }
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use std::process::Command;
334    use tempfile::TempDir;
335
336    fn git(args: &[&str], dir: &std::path::Path) -> bool {
337        Command::new("git")
338            .args(args)
339            .current_dir(dir)
340            .output()
341            .map(|o| o.status.success())
342            .unwrap_or(false)
343    }
344
345    #[test]
346    fn test_find_git_dir_normal_repo() {
347        let temp = TempDir::new().unwrap();
348        assert!(git(&["init"], temp.path()));
349
350        let git_dir = GriteContext::find_git_dir_at(temp.path()).unwrap();
351        assert_eq!(
352            git_dir.canonicalize().unwrap(),
353            temp.path().join(".git").canonicalize().unwrap()
354        );
355    }
356
357    #[test]
358    fn test_find_git_dir_worktree() {
359        use git2::Repository;
360
361        let temp = TempDir::new().unwrap();
362        let main_repo = temp.path().join("main");
363        let worktree_path = temp.path().join("feature");
364        std::fs::create_dir_all(&main_repo).unwrap();
365
366        assert!(git(&["init"], &main_repo));
367        assert!(git(&["config", "user.email", "test@test.com"], &main_repo));
368        assert!(git(&["config", "user.name", "Test"], &main_repo));
369        assert!(git(&["commit", "--allow-empty", "-m", "init"], &main_repo));
370
371        assert!(git(
372            &[
373                "worktree",
374                "add",
375                worktree_path.to_str().unwrap(),
376                "-b",
377                "feature"
378            ],
379            &main_repo
380        ));
381
382        let git_file = worktree_path.join(".git");
383        assert!(
384            git_file.is_file(),
385            ".git should be a file in worktree, not a directory"
386        );
387
388        let repo =
389            Repository::discover(&worktree_path).expect("Should discover repo from worktree");
390
391        let commondir = repo.commondir();
392        let expected_commondir = main_repo.join(".git").canonicalize().unwrap();
393        let actual_commondir = commondir.canonicalize().unwrap();
394        assert_eq!(actual_commondir, expected_commondir);
395
396        assert_ne!(
397            repo.path(),
398            repo.commondir(),
399            "In worktree, path() != commondir()"
400        );
401    }
402
403    #[test]
404    fn test_is_worktree_main_repo() {
405        let temp = TempDir::new().unwrap();
406        assert!(git(&["init"], temp.path()));
407
408        assert!(!GriteContext::is_worktree_at(temp.path()).unwrap());
409    }
410
411    #[test]
412    fn test_find_git_dir_subdirectory() {
413        let temp = TempDir::new().unwrap();
414        assert!(git(&["init"], temp.path()));
415
416        let subdir = temp.path().join("src").join("deep");
417        std::fs::create_dir_all(&subdir).unwrap();
418
419        let git_dir = GriteContext::find_git_dir_at(&subdir).unwrap();
420        assert_eq!(
421            git_dir.canonicalize().unwrap(),
422            temp.path().join(".git").canonicalize().unwrap()
423        );
424    }
425}