Skip to main content

dk_engine/git/
repository.rs

1use std::path::Path;
2
3use dk_core::{Error, Result};
4
5/// A wrapper around `gix::Repository` providing a simplified interface
6/// for Git repository operations.
7pub struct GitRepository {
8    inner: gix::Repository,
9}
10
11impl GitRepository {
12    /// Initialize a new Git repository at the given path.
13    ///
14    /// Creates the directory (and parents) if it does not exist, then
15    /// initializes a standard (non-bare) Git repository with a `.git` directory.
16    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    /// Open an existing Git repository at the given path.
29    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    /// Get the working directory path of the repository.
38    ///
39    /// Returns the working tree directory if available, otherwise falls back
40    /// to the `.git` directory itself.
41    pub fn path(&self) -> &Path {
42        self.inner
43            .workdir()
44            .unwrap_or_else(|| self.inner.git_dir())
45    }
46
47    /// Get a reference to the inner `gix::Repository`.
48    pub fn inner(&self) -> &gix::Repository {
49        &self.inner
50    }
51
52    /// Create a Git commit with the current working directory state.
53    /// Uses command-line git for simplicity (gix's commit API is complex).
54    pub fn commit(&self, message: &str, author_name: &str, author_email: &str) -> Result<String> {
55        let workdir = self.path();
56
57        // Stage all files
58        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    // ── NSI: Tree-based read/write operations ──────────────────────────
96
97    /// Read a file's content from a specific commit's tree (NOT the working directory).
98    /// This is the core isolation primitive for Native Session Isolation.
99    ///
100    /// `commit_hex` — hex SHA of the commit whose tree to read from.
101    /// `path` — relative file path within the tree (e.g. "src/main.rs").
102    ///
103    /// Returns the raw bytes of the blob, or an error if the commit / path
104    /// does not exist or the entry is not a blob.
105    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    /// List all files (recursive) in a commit's tree. Returns relative paths
139    /// using forward-slash separators.
140    ///
141    /// Only non-tree entries (blobs, symlinks, submodules) are included.
142    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    /// Build a new git tree by applying overlay changes on a base tree, create
172    /// a commit, and update the working directory.
173    ///
174    /// For each overlay entry:
175    /// - `Some(content)` → upsert a blob at that path
176    /// - `None` → delete the entry at that path
177    ///
178    /// After committing, the working directory is updated via `git checkout HEAD -- .`.
179    ///
180    /// Returns the hex SHA of the new commit.
181    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        // Parse the base commit to get its tree
193        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        // Parse the parent commit
207        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        // Create a tree editor from the base tree
211        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        // Apply each overlay entry
217        for (path, maybe_content) in overlay {
218            match maybe_content {
219                Some(content) => {
220                    // Write the blob to the object database
221                    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                    // Upsert into the tree — detect executable by file extension heuristic
227                    // (default to regular blob)
228                    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                    // Remove the entry from the tree
234                    editor
235                        .remove(path.as_str())
236                        .map_err(|e| Error::Git(format!("failed to remove '{path}': {e}")))?;
237                }
238            }
239        }
240
241        // Write the modified tree to the object database
242        let new_tree_id = editor
243            .write()
244            .map_err(|e| Error::Git(format!("failed to write edited tree: {e}")))?;
245
246        // Build the commit object with explicit author/committer
247        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        // Update the working directory to match the new commit.
274        // Spawn on a separate thread to avoid blocking the tokio async runtime
275        // when this function is called from an async context via spawn_blocking.
276        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            // Non-fatal: the commit succeeded, the working directory just wasn't updated.
289            // Log but don't fail.
290            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    /// Create an orphan commit from overlay entries on an empty repository
300    /// (no existing commits). This is used for the very first commit.
301    ///
302    /// Builds a tree from scratch using only the overlay entries (ignoring
303    /// `None`/deletion entries since there's nothing to delete), creates a
304    /// root commit with no parents, and updates HEAD.
305    ///
306    /// Returns the hex SHA of the new commit.
307    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        // Start from an empty tree and build up the initial file tree.
317        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        // Apply overlay entries (only additions, skip deletions)
327        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        // Build the commit with no parents (orphan/root commit)
345        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        // Root commit: no parents (empty iterator)
365        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        // Update working directory
380        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    /// Get the HEAD commit hash as a hex string, or `None` if the repository
402    /// is empty (no commits yet).
403    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}