1use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use git2::{Repository, Signature};
10
11pub const BEADS_DIR: &str = ".beads";
13
14pub const DB_FILE: &str = "beads.db";
16
17pub const JSONL_FILE: &str = "issues.jsonl";
19
20pub struct GitRepo {
22 repo: Repository,
23}
24
25impl GitRepo {
26 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 pub fn discover() -> Result<Self> {
35 let repo = Repository::discover(".")
36 .context("Failed to discover git repository")?;
37 Ok(Self { repo })
38 }
39
40 pub fn workdir(&self) -> Option<&Path> {
42 self.repo.workdir()
43 }
44
45 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 pub fn database_path(&self) -> Option<PathBuf> {
58 self.find_beads_dir().map(|p| p.join(DB_FILE))
59 }
60
61 pub fn jsonl_path(&self) -> Option<PathBuf> {
63 self.find_beads_dir().map(|p| p.join(JSONL_FILE))
64 }
65
66 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 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 pub fn stage_beads_files(&self) -> Result<()> {
94 let mut index = self.repo.index()
95 .context("Failed to get index")?;
96
97 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 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 pub fn commit_beads_changes(&self, message: &str, actor: &str) -> Result<git2::Oid> {
141 self.stage_beads_files()?;
142
143 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 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 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
167pub 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
183pub fn find_database_path() -> Option<PathBuf> {
185 find_beads_dir().map(|p| p.join(DB_FILE))
186}
187
188pub fn find_jsonl_path() -> Option<PathBuf> {
190 find_beads_dir().map(|p| p.join(JSONL_FILE))
191}
192
193pub 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 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 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}