rusty_beads/git/
mod.rs

1//! Git integration for Beads.
2//!
3//! Provides functionality for finding the .beads directory,
4//! committing changes, and syncing with remote repositories.
5
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use git2::{Repository, Signature};
10
11/// Name of the beads directory.
12pub const BEADS_DIR: &str = ".beads";
13
14/// Name of the database file.
15pub const DB_FILE: &str = "beads.db";
16
17/// Name of the JSONL file.
18pub const JSONL_FILE: &str = "issues.jsonl";
19
20/// Git repository wrapper for Beads operations.
21pub struct GitRepo {
22    repo: Repository,
23}
24
25impl GitRepo {
26    /// Open an existing git repository.
27    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
28        let repo = Repository::open(path.as_ref())
29            .with_context(|| format!("Failed to open git repository at {:?}", path.as_ref()))?;
30        Ok(Self { repo })
31    }
32
33    /// Discover and open the git repository from the current directory.
34    pub fn discover() -> Result<Self> {
35        let repo = Repository::discover(".")
36            .context("Failed to discover git repository")?;
37        Ok(Self { repo })
38    }
39
40    /// Get the repository's working directory.
41    pub fn workdir(&self) -> Option<&Path> {
42        self.repo.workdir()
43    }
44
45    /// Find the .beads directory in the repository.
46    pub fn find_beads_dir(&self) -> Option<PathBuf> {
47        let workdir = self.workdir()?;
48        let beads_path = workdir.join(BEADS_DIR);
49        if beads_path.exists() {
50            Some(beads_path)
51        } else {
52            None
53        }
54    }
55
56    /// Get the database path.
57    pub fn database_path(&self) -> Option<PathBuf> {
58        self.find_beads_dir().map(|p| p.join(DB_FILE))
59    }
60
61    /// Get the JSONL file path.
62    pub fn jsonl_path(&self) -> Option<PathBuf> {
63        self.find_beads_dir().map(|p| p.join(JSONL_FILE))
64    }
65
66    /// Get the current branch name.
67    pub fn current_branch(&self) -> Result<String> {
68        let head = self.repo.head()
69            .context("Failed to get HEAD")?;
70        let name = head.shorthand()
71            .unwrap_or("HEAD")
72            .to_string();
73        Ok(name)
74    }
75
76    /// Check if there are uncommitted changes in the .beads directory.
77    pub fn has_beads_changes(&self) -> Result<bool> {
78        let statuses = self.repo.statuses(None)
79            .context("Failed to get repository status")?;
80
81        for status in statuses.iter() {
82            if let Some(path) = status.path() {
83                if path.starts_with(BEADS_DIR) {
84                    return Ok(true);
85                }
86            }
87        }
88
89        Ok(false)
90    }
91
92    /// Stage files in the .beads directory.
93    pub fn stage_beads_files(&self) -> Result<()> {
94        let mut index = self.repo.index()
95            .context("Failed to get index")?;
96
97        // Add all files in .beads directory
98        index.add_all([BEADS_DIR], git2::IndexAddOption::DEFAULT, None)
99            .context("Failed to stage .beads files")?;
100
101        index.write()
102            .context("Failed to write index")?;
103
104        Ok(())
105    }
106
107    /// Commit staged changes.
108    pub fn commit(&self, message: &str, author_name: &str, author_email: &str) -> Result<git2::Oid> {
109        let mut index = self.repo.index()
110            .context("Failed to get index")?;
111
112        let tree_id = index.write_tree()
113            .context("Failed to write tree")?;
114
115        let tree = self.repo.find_tree(tree_id)
116            .context("Failed to find tree")?;
117
118        let signature = Signature::now(author_name, author_email)
119            .context("Failed to create signature")?;
120
121        let head = self.repo.head()
122            .context("Failed to get HEAD")?;
123
124        let parent = self.repo.find_commit(head.target().unwrap())
125            .context("Failed to find parent commit")?;
126
127        let oid = self.repo.commit(
128            Some("HEAD"),
129            &signature,
130            &signature,
131            message,
132            &tree,
133            &[&parent],
134        ).context("Failed to create commit")?;
135
136        Ok(oid)
137    }
138
139    /// Commit changes with the default beads signature.
140    pub fn commit_beads_changes(&self, message: &str, actor: &str) -> Result<git2::Oid> {
141        self.stage_beads_files()?;
142
143        // Try to get git config for email, fall back to a default
144        let email = self.repo.config()
145            .ok()
146            .and_then(|cfg| cfg.get_string("user.email").ok())
147            .unwrap_or_else(|| format!("{}@beads", actor));
148
149        self.commit(message, actor, &email)
150    }
151
152    /// Get the user name from git config.
153    pub fn get_user_name(&self) -> Option<String> {
154        self.repo.config()
155            .ok()
156            .and_then(|cfg| cfg.get_string("user.name").ok())
157    }
158
159    /// Get the user email from git config.
160    pub fn get_user_email(&self) -> Option<String> {
161        self.repo.config()
162            .ok()
163            .and_then(|cfg| cfg.get_string("user.email").ok())
164    }
165}
166
167/// Find the .beads directory by searching up the directory tree.
168pub fn find_beads_dir() -> Option<PathBuf> {
169    let mut current = std::env::current_dir().ok()?;
170
171    loop {
172        let beads_path = current.join(BEADS_DIR);
173        if beads_path.exists() && beads_path.is_dir() {
174            return Some(beads_path);
175        }
176
177        if !current.pop() {
178            return None;
179        }
180    }
181}
182
183/// Find the database path by searching for .beads directory.
184pub fn find_database_path() -> Option<PathBuf> {
185    find_beads_dir().map(|p| p.join(DB_FILE))
186}
187
188/// Find the JSONL file path by searching for .beads directory.
189pub fn find_jsonl_path() -> Option<PathBuf> {
190    find_beads_dir().map(|p| p.join(JSONL_FILE))
191}
192
193/// Initialize the .beads directory.
194pub fn init_beads_dir(path: impl AsRef<Path>) -> Result<PathBuf> {
195    let beads_path = path.as_ref().join(BEADS_DIR);
196    std::fs::create_dir_all(&beads_path)
197        .with_context(|| format!("Failed to create .beads directory at {:?}", beads_path))?;
198    Ok(beads_path)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use tempfile::TempDir;
205
206    #[test]
207    fn test_find_beads_dir_in_current() {
208        let temp = TempDir::new().unwrap();
209        let beads_path = temp.path().join(BEADS_DIR);
210        std::fs::create_dir(&beads_path).unwrap();
211
212        // Change to temp directory and verify we find it
213        let old_dir = std::env::current_dir().unwrap();
214        std::env::set_current_dir(temp.path()).unwrap();
215
216        let found = find_beads_dir();
217        assert!(found.is_some());
218        // Compare file names only to avoid macOS /private symlink issues
219        assert!(found.unwrap().ends_with(BEADS_DIR));
220
221        std::env::set_current_dir(old_dir).unwrap();
222    }
223
224    #[test]
225    fn test_init_beads_dir() {
226        let temp = TempDir::new().unwrap();
227        let result = init_beads_dir(temp.path());
228
229        assert!(result.is_ok());
230        assert!(temp.path().join(BEADS_DIR).exists());
231    }
232}