dk_engine/git/
repository.rs1use std::path::Path;
2
3use dk_core::{Error, Result};
4
5pub struct GitRepository {
8 inner: gix::Repository,
9}
10
11impl GitRepository {
12 pub fn init(path: &Path) -> Result<Self> {
17 std::fs::create_dir_all(path).map_err(|e| {
18 Error::Git(format!("failed to create directory {}: {}", path.display(), e))
19 })?;
20
21 let repo = gix::init(path).map_err(|e| {
22 Error::Git(format!("failed to init repository at {}: {}", path.display(), e))
23 })?;
24
25 Ok(Self { inner: repo })
26 }
27
28 pub fn open(path: &Path) -> Result<Self> {
30 let repo = gix::open(path).map_err(|e| {
31 Error::Git(format!("failed to open repository at {}: {}", path.display(), e))
32 })?;
33
34 Ok(Self { inner: repo })
35 }
36
37 pub fn path(&self) -> &Path {
42 self.inner
43 .workdir()
44 .unwrap_or_else(|| self.inner.git_dir())
45 }
46
47 pub fn inner(&self) -> &gix::Repository {
49 &self.inner
50 }
51
52 pub fn commit(&self, message: &str, author_name: &str, author_email: &str) -> Result<String> {
55 let workdir = self.path();
56
57 let output = std::process::Command::new("git")
59 .args(["add", "-A"])
60 .current_dir(workdir)
61 .output()
62 .map_err(|e| Error::Git(format!("git add failed: {e}")))?;
63
64 if !output.status.success() {
65 return Err(Error::Git(format!(
66 "git add failed: {}",
67 String::from_utf8_lossy(&output.stderr)
68 )));
69 }
70
71 let output = std::process::Command::new("git")
72 .args([
73 "commit",
74 "--allow-empty",
75 "-m", message,
76 "--author", &format!("{} <{}>", author_name, author_email),
77 ])
78 .current_dir(workdir)
79 .output()
80 .map_err(|e| Error::Git(format!("git commit failed: {e}")))?;
81
82 if !output.status.success() {
83 let stderr = String::from_utf8_lossy(&output.stderr);
84 if stderr.contains("nothing to commit") {
85 return self.head_hash()?
86 .ok_or_else(|| Error::Git("no HEAD after commit".into()));
87 }
88 return Err(Error::Git(format!("git commit failed: {stderr}")));
89 }
90
91 self.head_hash()?
92 .ok_or_else(|| Error::Git("no HEAD after commit".into()))
93 }
94
95 pub fn read_tree_entry(&self, commit_hex: &str, path: &str) -> Result<Vec<u8>> {
106 let oid = gix::ObjectId::from_hex(commit_hex.as_bytes())
107 .map_err(|e| Error::Git(format!("invalid commit hex '{commit_hex}': {e}")))?;
108
109 let commit = self
110 .inner
111 .find_commit(oid)
112 .map_err(|e| Error::Git(format!("failed to find commit {commit_hex}: {e}")))?;
113
114 let tree = self
115 .inner
116 .find_tree(commit.tree_id().expect("commit always has tree"))
117 .map_err(|e| Error::Git(format!("failed to find tree for commit {commit_hex}: {e}")))?;
118
119 let entry = tree
120 .lookup_entry_by_path(path)
121 .map_err(|e| Error::Git(format!("failed to lookup '{path}' in {commit_hex}: {e}")))?
122 .ok_or_else(|| Error::Git(format!("path '{path}' not found in commit {commit_hex}")))?;
123
124 let object = entry
125 .object()
126 .map_err(|e| Error::Git(format!("failed to read object for '{path}': {e}")))?;
127
128 if object.kind != gix::object::Kind::Blob {
129 return Err(Error::Git(format!(
130 "path '{path}' in commit {commit_hex} is not a blob (is {:?})",
131 object.kind
132 )));
133 }
134
135 Ok(object.data.clone())
136 }
137
138 pub fn list_tree_files(&self, commit_hex: &str) -> Result<Vec<String>> {
143 let oid = gix::ObjectId::from_hex(commit_hex.as_bytes())
144 .map_err(|e| Error::Git(format!("invalid commit hex '{commit_hex}': {e}")))?;
145
146 let commit = self
147 .inner
148 .find_commit(oid)
149 .map_err(|e| Error::Git(format!("failed to find commit {commit_hex}: {e}")))?;
150
151 let tree = self
152 .inner
153 .find_tree(commit.tree_id().expect("commit always has tree"))
154 .map_err(|e| Error::Git(format!("failed to find tree for commit {commit_hex}: {e}")))?;
155
156 let entries = tree
157 .traverse()
158 .breadthfirst
159 .files()
160 .map_err(|e| Error::Git(format!("tree traversal failed for {commit_hex}: {e}")))?;
161
162 let paths = entries
163 .into_iter()
164 .filter(|e| !e.mode.is_tree())
165 .map(|e| e.filepath.to_string())
166 .collect();
167
168 Ok(paths)
169 }
170
171 pub fn commit_tree_overlay(
182 &self,
183 base_commit_hex: &str,
184 overlay: &[(String, Option<Vec<u8>>)],
185 parent_commit_hex: &str,
186 message: &str,
187 author_name: &str,
188 author_email: &str,
189 ) -> Result<String> {
190 use gix::object::tree::EntryKind;
191
192 let base_oid = gix::ObjectId::from_hex(base_commit_hex.as_bytes())
194 .map_err(|e| Error::Git(format!("invalid base commit hex '{base_commit_hex}': {e}")))?;
195
196 let base_commit = self
197 .inner
198 .find_commit(base_oid)
199 .map_err(|e| Error::Git(format!("failed to find base commit {base_commit_hex}: {e}")))?;
200
201 let base_tree = self
202 .inner
203 .find_tree(base_commit.tree_id().expect("commit always has tree"))
204 .map_err(|e| Error::Git(format!("failed to find base tree: {e}")))?;
205
206 let parent_oid = gix::ObjectId::from_hex(parent_commit_hex.as_bytes())
208 .map_err(|e| Error::Git(format!("invalid parent commit hex '{parent_commit_hex}': {e}")))?;
209
210 let mut editor = self
212 .inner
213 .edit_tree(base_tree.id)
214 .map_err(|e| Error::Git(format!("failed to create tree editor: {e}")))?;
215
216 for (path, maybe_content) in overlay {
218 match maybe_content {
219 Some(content) => {
220 let blob_id = self
222 .inner
223 .write_blob(content)
224 .map_err(|e| Error::Git(format!("failed to write blob for '{path}': {e}")))?;
225
226 editor
229 .upsert(path.as_str(), EntryKind::Blob, blob_id.detach())
230 .map_err(|e| Error::Git(format!("failed to upsert '{path}': {e}")))?;
231 }
232 None => {
233 editor
235 .remove(path.as_str())
236 .map_err(|e| Error::Git(format!("failed to remove '{path}': {e}")))?;
237 }
238 }
239 }
240
241 let new_tree_id = editor
243 .write()
244 .map_err(|e| Error::Git(format!("failed to write edited tree: {e}")))?;
245
246 let now_secs = std::time::SystemTime::now()
248 .duration_since(std::time::UNIX_EPOCH)
249 .unwrap_or_default()
250 .as_secs() as i64;
251
252 let time = gix::date::Time {
253 seconds: now_secs,
254 offset: 0,
255 };
256
257 let sig = gix::actor::Signature {
258 name: author_name.into(),
259 email: author_email.into(),
260 time,
261 };
262
263 let mut time_buf = gix::date::parse::TimeBuf::default();
264 let sig_ref = sig.to_ref(&mut time_buf);
265
266 let commit_id = self
267 .inner
268 .commit_as(sig_ref, sig_ref, "HEAD", message, new_tree_id.detach(), [parent_oid])
269 .map_err(|e| Error::Git(format!("failed to create commit: {e}")))?;
270
271 let commit_hex = commit_id.to_hex().to_string();
272
273 let work_dir = self.path().to_path_buf();
277 let output = std::thread::spawn(move || {
278 std::process::Command::new("git")
279 .args(["checkout", "HEAD", "--", "."])
280 .current_dir(&work_dir)
281 .output()
282 })
283 .join()
284 .map_err(|_| Error::Git("git checkout thread panicked".into()))?
285 .map_err(|e| Error::Git(format!("git checkout failed: {e}")))?;
286
287 if !output.status.success() {
288 tracing::warn!(
291 "git checkout HEAD -- . failed after commit: {}",
292 String::from_utf8_lossy(&output.stderr)
293 );
294 }
295
296 Ok(commit_hex)
297 }
298
299 pub fn commit_initial_overlay(
308 &self,
309 overlay: &[(String, Option<Vec<u8>>)],
310 message: &str,
311 author_name: &str,
312 author_email: &str,
313 ) -> Result<String> {
314 use gix::object::tree::EntryKind;
315
316 let empty_tree = self
318 .inner
319 .empty_tree();
320
321 let mut editor = self
322 .inner
323 .edit_tree(empty_tree.id)
324 .map_err(|e| Error::Git(format!("failed to create tree editor: {e}")))?;
325
326 for (path, maybe_content) in overlay {
328 if let Some(content) = maybe_content {
329 let blob_id = self
330 .inner
331 .write_blob(content)
332 .map_err(|e| Error::Git(format!("failed to write blob for '{path}': {e}")))?;
333
334 editor
335 .upsert(path.as_str(), EntryKind::Blob, blob_id.detach())
336 .map_err(|e| Error::Git(format!("failed to upsert '{path}': {e}")))?;
337 }
338 }
339
340 let new_tree_id = editor
341 .write()
342 .map_err(|e| Error::Git(format!("failed to write initial tree: {e}")))?;
343
344 let now_secs = std::time::SystemTime::now()
346 .duration_since(std::time::UNIX_EPOCH)
347 .unwrap_or_default()
348 .as_secs() as i64;
349
350 let time = gix::date::Time {
351 seconds: now_secs,
352 offset: 0,
353 };
354
355 let sig = gix::actor::Signature {
356 name: author_name.into(),
357 email: author_email.into(),
358 time,
359 };
360
361 let mut time_buf = gix::date::parse::TimeBuf::default();
362 let sig_ref = sig.to_ref(&mut time_buf);
363
364 let commit_id = self
366 .inner
367 .commit_as(
368 sig_ref,
369 sig_ref,
370 "HEAD",
371 message,
372 new_tree_id.detach(),
373 std::iter::empty::<gix::ObjectId>(),
374 )
375 .map_err(|e| Error::Git(format!("failed to create initial commit: {e}")))?;
376
377 let commit_hex = commit_id.to_hex().to_string();
378
379 let work_dir = self.path().to_path_buf();
381 let output = std::thread::spawn(move || {
382 std::process::Command::new("git")
383 .args(["checkout", "HEAD", "--", "."])
384 .current_dir(&work_dir)
385 .output()
386 })
387 .join()
388 .map_err(|_| Error::Git("git checkout thread panicked".into()))?
389 .map_err(|e| Error::Git(format!("git checkout failed: {e}")))?;
390
391 if !output.status.success() {
392 tracing::warn!(
393 "git checkout HEAD -- . failed after initial commit: {}",
394 String::from_utf8_lossy(&output.stderr)
395 );
396 }
397
398 Ok(commit_hex)
399 }
400
401 pub fn head_hash(&self) -> Result<Option<String>> {
404 let head = self
405 .inner
406 .head()
407 .map_err(|e| Error::Git(format!("failed to get HEAD: {}", e)))?;
408
409 if head.is_unborn() {
410 return Ok(None);
411 }
412
413 match head.into_peeled_id() {
414 Ok(id) => Ok(Some(id.to_hex().to_string())),
415 Err(e) => Err(Error::Git(format!("failed to peel HEAD: {}", e))),
416 }
417 }
418}