Skip to main content

codex_git_utils/
baseline.rs

1use anyhow::Context;
2use gix::hash::ObjectId;
3use gix::objs::Tree;
4use gix::objs::tree::Entry;
5use gix::objs::tree::EntryKind;
6use gix::objs::tree::EntryMode;
7use similar::TextDiff;
8use std::collections::BTreeMap;
9use std::ffi::OsStr;
10use std::fs;
11use std::path::Path;
12use std::path::PathBuf;
13use tokio::task;
14
15use crate::operations::run_git_for_status;
16
17const BASELINE_COMMIT_MESSAGE: &str =
18    "Initialize Codex git baseline\n\nCo-authored-by: Codex <noreply@openai.com>";
19
20/// File-level change status between a git baseline and the current directory.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum GitBaselineChangeStatus {
23    Added,
24    Modified,
25    Deleted,
26}
27
28impl GitBaselineChangeStatus {
29    /// Returns the short git-style status label for this change.
30    pub fn label(self) -> &'static str {
31        match self {
32            GitBaselineChangeStatus::Added => "A",
33            GitBaselineChangeStatus::Modified => "M",
34            GitBaselineChangeStatus::Deleted => "D",
35        }
36    }
37}
38
39/// One changed file between a git baseline and the current directory.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct GitBaselineChange {
42    pub status: GitBaselineChangeStatus,
43    pub path: String,
44}
45
46/// Structured diff from the latest git baseline reset to the current directory.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct GitBaselineDiff {
49    pub changes: Vec<GitBaselineChange>,
50    pub unified_diff: String,
51}
52
53impl GitBaselineDiff {
54    pub fn has_changes(&self) -> bool {
55        !self.changes.is_empty()
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60struct GitBaselineFileEntry {
61    oid: ObjectId,
62    mode: EntryMode,
63}
64
65/// Replaces any existing `.git` metadata in `root` with a fresh one-commit baseline.
66///
67/// This is intentionally destructive for `root/.git`. It is meant for internal directories where
68/// git is used only as a baseline/diff implementation detail, not for user repositories.
69pub async fn reset_git_repository(root: &Path) -> anyhow::Result<()> {
70    let root = root.to_path_buf();
71    task::spawn_blocking(move || reset_git_repository_sync(&root)).await?
72}
73
74/// Ensures `root` has a usable git baseline repository.
75///
76/// Existing usable `.git/` metadata is preserved. Missing or unusable metadata is replaced with a
77/// fresh one-commit baseline.
78pub async fn ensure_git_baseline_repository(root: &Path) -> anyhow::Result<()> {
79    let root = root.to_path_buf();
80    task::spawn_blocking(move || {
81        fs::create_dir_all(&root)
82            .with_context(|| format!("create git baseline root {}", root.display()))?;
83        if root.join(".git").is_dir()
84            && let Ok(repo) = gix::open(&root)
85            && head_file_entries(&repo).is_ok()
86        {
87            return Ok(());
88        }
89        reset_git_repository_sync(&root)
90    })
91    .await?
92}
93
94fn reset_git_repository_sync(root: &Path) -> anyhow::Result<()> {
95    fs::create_dir_all(root)
96        .with_context(|| format!("create git baseline root {}", root.display()))?;
97    remove_git_metadata(root)?;
98    let repo = gix::init(root).with_context(|| format!("init git repo {}", root.display()))?;
99    commit_current_tree(&repo, BASELINE_COMMIT_MESSAGE)?;
100    write_index_from_head(root)?;
101    Ok(())
102}
103
104/// Returns the diff between the latest baseline reset and the current directory contents.
105pub async fn diff_since_latest_init(root: &Path) -> anyhow::Result<GitBaselineDiff> {
106    let root = root.to_path_buf();
107    task::spawn_blocking(move || {
108        let repo = gix::open(&root).with_context(|| format!("open git repo {}", root.display()))?;
109        let head_entries = head_file_entries(&repo)?;
110        let current_entries = current_file_entries(&repo, &root)?;
111        let changes = diff_entries(&head_entries, &current_entries);
112        let unified_diff =
113            render_unified_diff(&repo, &root, &head_entries, &current_entries, &changes)?;
114        Ok(GitBaselineDiff {
115            changes,
116            unified_diff,
117        })
118    })
119    .await?
120}
121
122fn remove_git_metadata(root: &Path) -> anyhow::Result<()> {
123    let git_path = root.join(".git");
124    let metadata = match fs::symlink_metadata(&git_path) {
125        Ok(metadata) => metadata,
126        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
127        Err(err) => return Err(err).with_context(|| format!("stat {}", git_path.display())),
128    };
129
130    if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() {
131        fs::remove_dir_all(&git_path).with_context(|| format!("remove {}", git_path.display()))
132    } else {
133        fs::remove_file(&git_path).with_context(|| format!("remove {}", git_path.display()))
134    }
135}
136
137fn commit_current_tree(repo: &gix::Repository, message: &str) -> anyhow::Result<()> {
138    let root = repo
139        .workdir()
140        .context("git baseline repo must have a worktree")?;
141    let tree_id = write_tree(repo, root)?;
142    let signature = codex_signature();
143    let mut time = gix::date::parse::TimeBuf::default();
144    let signature_ref = signature.to_ref(&mut time);
145    repo.commit_as(
146        signature_ref,
147        signature_ref,
148        "HEAD",
149        message,
150        tree_id,
151        Vec::<ObjectId>::new(),
152    )
153    .context("commit git baseline repo")?;
154    Ok(())
155}
156
157fn write_index_from_head(root: &Path) -> anyhow::Result<()> {
158    run_git_for_status(root, ["read-tree", "--reset", "HEAD"], /*env*/ None)
159        .context("write git baseline index from HEAD")
160}
161
162fn codex_signature() -> gix::actor::Signature {
163    gix::actor::Signature {
164        name: "Codex".into(),
165        email: "noreply@openai.com".into(),
166        time: gix::date::Time {
167            seconds: chrono::Utc::now().timestamp(),
168            offset: 0,
169        },
170    }
171}
172
173fn write_tree(repo: &gix::Repository, dir: &Path) -> anyhow::Result<ObjectId> {
174    let mut entries = Vec::new();
175    for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
176        let entry = entry?;
177        let path = entry.path();
178        let file_name = entry.file_name();
179        if file_name == OsStr::new(".git") {
180            continue;
181        }
182
183        let file_type = entry.file_type()?;
184        if file_type.is_dir() {
185            let oid = write_tree(repo, &path)?;
186            let tree = repo
187                .find_tree(oid)
188                .with_context(|| format!("load tree {}", path.display()))?;
189            if tree.decode()?.entries.is_empty() {
190                continue;
191            }
192            entries.push(Entry {
193                mode: EntryKind::Tree.into(),
194                filename: os_str_to_bstring(&file_name),
195                oid,
196            });
197        } else if file_type.is_file() {
198            let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
199            let oid = repo
200                .write_blob(bytes)
201                .with_context(|| format!("write blob {}", path.display()))?
202                .detach();
203            entries.push(Entry {
204                mode: file_mode(&path, EntryKind::Blob)?,
205                filename: os_str_to_bstring(&file_name),
206                oid,
207            });
208        } else if file_type.is_symlink() {
209            let target =
210                fs::read_link(&path).with_context(|| format!("read symlink {}", path.display()))?;
211            let oid = repo
212                .write_blob(path_to_bytes(&target))
213                .with_context(|| format!("write symlink blob {}", path.display()))?
214                .detach();
215            entries.push(Entry {
216                mode: EntryKind::Link.into(),
217                filename: os_str_to_bstring(&file_name),
218                oid,
219            });
220        }
221    }
222
223    entries.sort();
224    repo.write_object(&Tree { entries })
225        .context("write tree object")
226        .map(gix::Id::detach)
227}
228
229fn head_file_entries(
230    repo: &gix::Repository,
231) -> anyhow::Result<BTreeMap<String, GitBaselineFileEntry>> {
232    let tree_id = repo.head_tree_id().context("load HEAD tree id")?;
233    let tree = repo.find_tree(tree_id.detach()).context("load HEAD tree")?;
234    let mut entries = BTreeMap::new();
235    collect_tree_entries(repo, tree, PathBuf::new(), &mut entries)?;
236    Ok(entries)
237}
238
239fn collect_tree_entries(
240    repo: &gix::Repository,
241    tree: gix::Tree<'_>,
242    prefix: PathBuf,
243    entries: &mut BTreeMap<String, GitBaselineFileEntry>,
244) -> anyhow::Result<()> {
245    for entry in tree.iter() {
246        let entry = entry?;
247        let file_name = bstr_to_path(entry.inner.filename);
248        let path = prefix.join(file_name);
249        if entry.inner.mode.is_tree() {
250            let tree = repo
251                .find_tree(entry.inner.oid.to_owned())
252                .context("load child tree")?;
253            collect_tree_entries(repo, tree, path, entries)?;
254        } else {
255            entries.insert(
256                path_to_slash_string(&path),
257                GitBaselineFileEntry {
258                    oid: entry.inner.oid.to_owned(),
259                    mode: entry.inner.mode,
260                },
261            );
262        }
263    }
264    Ok(())
265}
266
267fn current_file_entries(
268    repo: &gix::Repository,
269    root: &Path,
270) -> anyhow::Result<BTreeMap<String, GitBaselineFileEntry>> {
271    let mut entries = BTreeMap::new();
272    collect_current_entries(repo, root, root, &mut entries)?;
273    Ok(entries)
274}
275
276fn collect_current_entries(
277    repo: &gix::Repository,
278    root: &Path,
279    dir: &Path,
280    entries: &mut BTreeMap<String, GitBaselineFileEntry>,
281) -> anyhow::Result<()> {
282    for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
283        let entry = entry?;
284        let path = entry.path();
285        if path.file_name() == Some(OsStr::new(".git")) {
286            continue;
287        }
288
289        let file_type = entry.file_type()?;
290        if file_type.is_dir() {
291            collect_current_entries(repo, root, &path, entries)?;
292        } else if file_type.is_file() {
293            let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
294            entries.insert(
295                relative_slash_path(root, &path)?,
296                GitBaselineFileEntry {
297                    oid: blob_oid(repo, &bytes)?,
298                    mode: file_mode(&path, EntryKind::Blob)?,
299                },
300            );
301        } else if file_type.is_symlink() {
302            let target =
303                fs::read_link(&path).with_context(|| format!("read symlink {}", path.display()))?;
304            entries.insert(
305                relative_slash_path(root, &path)?,
306                GitBaselineFileEntry {
307                    oid: blob_oid(repo, &path_to_bytes(&target))?,
308                    mode: EntryKind::Link.into(),
309                },
310            );
311        }
312    }
313    Ok(())
314}
315
316fn blob_oid(repo: &gix::Repository, bytes: &[u8]) -> anyhow::Result<ObjectId> {
317    gix::objs::compute_hash(repo.object_hash(), gix::objs::Kind::Blob, bytes)
318        .context("compute git baseline blob oid")
319}
320
321fn diff_entries(
322    head: &BTreeMap<String, GitBaselineFileEntry>,
323    current: &BTreeMap<String, GitBaselineFileEntry>,
324) -> Vec<GitBaselineChange> {
325    let mut entries = Vec::new();
326    for (path, entry) in current {
327        match head.get(path) {
328            None => entries.push(GitBaselineChange {
329                status: GitBaselineChangeStatus::Added,
330                path: path.clone(),
331            }),
332            Some(head_entry) if head_entry != entry => entries.push(GitBaselineChange {
333                status: GitBaselineChangeStatus::Modified,
334                path: path.clone(),
335            }),
336            Some(_) => {}
337        }
338    }
339    for path in head.keys() {
340        if !current.contains_key(path) {
341            entries.push(GitBaselineChange {
342                status: GitBaselineChangeStatus::Deleted,
343                path: path.clone(),
344            });
345        }
346    }
347    entries.sort_by(|left, right| left.path.cmp(&right.path));
348    entries
349}
350
351fn render_unified_diff(
352    repo: &gix::Repository,
353    root: &Path,
354    head_entries: &BTreeMap<String, GitBaselineFileEntry>,
355    current_entries: &BTreeMap<String, GitBaselineFileEntry>,
356    changes: &[GitBaselineChange],
357) -> anyhow::Result<String> {
358    let mut rendered = String::new();
359    for change in changes {
360        rendered.push_str(&render_change_diff(
361            repo,
362            root,
363            head_entries,
364            current_entries,
365            change,
366        )?);
367    }
368    Ok(rendered)
369}
370
371fn render_change_diff(
372    repo: &gix::Repository,
373    root: &Path,
374    head_entries: &BTreeMap<String, GitBaselineFileEntry>,
375    current_entries: &BTreeMap<String, GitBaselineFileEntry>,
376    change: &GitBaselineChange,
377) -> anyhow::Result<String> {
378    let old_entry = head_entries.get(&change.path);
379    let new_entry = current_entries.get(&change.path);
380    let old_bytes = old_entry
381        .map(|entry| read_head_blob(repo, entry))
382        .transpose()
383        .with_context(|| format!("read HEAD content for {}", change.path))?;
384    let new_bytes = new_entry
385        .map(|_| read_current_file_bytes(root, &change.path))
386        .transpose()
387        .with_context(|| format!("read current content for {}", change.path))?;
388
389    let old_text = String::from_utf8_lossy(old_bytes.as_deref().unwrap_or_default());
390    let new_text = String::from_utf8_lossy(new_bytes.as_deref().unwrap_or_default());
391    let old_header = if old_bytes.is_some() {
392        format!("a/{}", change.path)
393    } else {
394        "/dev/null".to_string()
395    };
396    let new_header = if new_bytes.is_some() {
397        format!("b/{}", change.path)
398    } else {
399        "/dev/null".to_string()
400    };
401
402    let mut section = format!("diff --git a/{0} b/{0}\n", change.path);
403    match (old_entry, new_entry) {
404        (None, Some(entry)) => {
405            section.push_str(&format!("new file mode {}\n", mode_label(entry.mode)));
406        }
407        (Some(entry), None) => {
408            section.push_str(&format!("deleted file mode {}\n", mode_label(entry.mode)));
409        }
410        (Some(old), Some(new)) if old.mode != new.mode => {
411            section.push_str(&format!(
412                "old mode {}\nnew mode {}\n",
413                mode_label(old.mode),
414                mode_label(new.mode)
415            ));
416        }
417        (Some(_), Some(_)) => {}
418        (None, None) => return Ok(String::new()),
419    }
420
421    let diff = TextDiff::from_lines(&old_text, &new_text)
422        .unified_diff()
423        .context_radius(3)
424        .header(&old_header, &new_header)
425        .to_string();
426    section.push_str(&diff);
427    if !section.ends_with('\n') {
428        section.push('\n');
429    }
430    Ok(section)
431}
432
433fn read_head_blob(repo: &gix::Repository, entry: &GitBaselineFileEntry) -> anyhow::Result<Vec<u8>> {
434    let mut blob = repo.find_blob(entry.oid)?;
435    Ok(blob.take_data())
436}
437
438fn read_current_file_bytes(root: &Path, relative_path: &str) -> anyhow::Result<Vec<u8>> {
439    let path = root.join(relative_path);
440    let metadata =
441        fs::symlink_metadata(&path).with_context(|| format!("stat {}", path.display()))?;
442    if metadata.file_type().is_symlink() {
443        let target =
444            fs::read_link(&path).with_context(|| format!("read symlink {}", path.display()))?;
445        Ok(path_to_bytes(&target))
446    } else {
447        fs::read(&path).with_context(|| format!("read {}", path.display()))
448    }
449}
450
451fn mode_label(mode: EntryMode) -> &'static str {
452    match mode.kind() {
453        EntryKind::Blob => "100644",
454        EntryKind::BlobExecutable => "100755",
455        EntryKind::Link => "120000",
456        EntryKind::Tree => "040000",
457        EntryKind::Commit => "160000",
458    }
459}
460
461#[cfg(unix)]
462fn file_mode(path: &Path, default: EntryKind) -> anyhow::Result<EntryMode> {
463    use std::os::unix::fs::PermissionsExt;
464
465    let mode = fs::metadata(path)?.permissions().mode();
466    Ok(if mode & 0o111 == 0 {
467        default.into()
468    } else {
469        EntryKind::BlobExecutable.into()
470    })
471}
472
473#[cfg(not(unix))]
474fn file_mode(_path: &Path, default: EntryKind) -> anyhow::Result<EntryMode> {
475    Ok(default.into())
476}
477
478#[cfg(unix)]
479fn os_str_to_bstring(value: &OsStr) -> gix::bstr::BString {
480    use std::os::unix::ffi::OsStrExt;
481
482    value.as_bytes().into()
483}
484
485#[cfg(not(unix))]
486fn os_str_to_bstring(value: &OsStr) -> gix::bstr::BString {
487    value.to_string_lossy().as_bytes().into()
488}
489
490#[cfg(unix)]
491fn path_to_bytes(path: &Path) -> Vec<u8> {
492    use std::os::unix::ffi::OsStrExt;
493
494    path.as_os_str().as_bytes().to_vec()
495}
496
497#[cfg(not(unix))]
498fn path_to_bytes(path: &Path) -> Vec<u8> {
499    path.to_string_lossy().as_bytes().to_vec()
500}
501
502fn bstr_to_path(value: &gix::bstr::BStr) -> PathBuf {
503    #[cfg(unix)]
504    {
505        use std::os::unix::ffi::OsStrExt;
506
507        PathBuf::from(OsStr::from_bytes(value))
508    }
509    #[cfg(not(unix))]
510    {
511        PathBuf::from(value.to_string())
512    }
513}
514
515fn relative_slash_path(root: &Path, path: &Path) -> anyhow::Result<String> {
516    path.strip_prefix(root)
517        .with_context(|| format!("strip {} from {}", root.display(), path.display()))
518        .map(path_to_slash_string)
519}
520
521fn path_to_slash_string(path: &Path) -> String {
522    path.components()
523        .map(|component| component.as_os_str().to_string_lossy())
524        .collect::<Vec<_>>()
525        .join("/")
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use pretty_assertions::assert_eq;
532    use std::fs;
533    use std::process::Command;
534    use tempfile::TempDir;
535
536    fn git_stdout(root: &Path, args: &[&str]) -> String {
537        let output = Command::new("git")
538            .current_dir(root)
539            .args(args)
540            .output()
541            .expect("run git command");
542        assert!(
543            output.status.success(),
544            "git command failed: {args:?}\nstdout:\n{}\nstderr:\n{}",
545            String::from_utf8_lossy(&output.stdout),
546            String::from_utf8_lossy(&output.stderr)
547        );
548        String::from_utf8_lossy(&output.stdout).to_string()
549    }
550
551    #[tokio::test]
552    async fn reset_creates_fresh_baseline() {
553        let home = TempDir::new().expect("tempdir");
554        let root = home.path().join("repo");
555        fs::create_dir_all(&root).expect("create root");
556        fs::write(root.join("MEMORY.md"), "baseline").expect("write memory");
557
558        reset_git_repository(&root).await.expect("reset repo");
559
560        assert!(root.join(".git").is_dir());
561        assert!(root.join(".git/index").is_file());
562        let diff = diff_since_latest_init(&root).await.expect("diff");
563        assert!(!diff.has_changes());
564        assert_eq!(diff.unified_diff, "");
565        assert_eq!(git_stdout(&root, &["status", "--porcelain"]), "");
566        assert_eq!(git_stdout(&root, &["ls-files"]), "MEMORY.md\n");
567    }
568
569    #[tokio::test]
570    async fn ensure_recovers_from_unborn_repository() {
571        let home = TempDir::new().expect("tempdir");
572        let root = home.path().join("repo");
573        fs::create_dir_all(&root).expect("create root");
574        fs::write(root.join("MEMORY.md"), "memory").expect("write memory");
575        gix::init(&root).expect("init git repo without baseline commit");
576
577        ensure_git_baseline_repository(&root)
578            .await
579            .expect("ensure repo");
580
581        let diff = diff_since_latest_init(&root).await.expect("diff");
582        assert!(!diff.has_changes());
583        assert_eq!(git_stdout(&root, &["status", "--porcelain"]), "");
584        assert_eq!(git_stdout(&root, &["ls-files"]), "MEMORY.md\n");
585    }
586
587    #[cfg(unix)]
588    #[tokio::test]
589    async fn write_index_ignores_configured_hooks_path() {
590        use std::os::unix::fs::PermissionsExt;
591
592        let home = TempDir::new().expect("tempdir");
593        let root = home.path().join("repo");
594        let hooks_dir = root.join(".git/hooks-path-test");
595        let marker_path = root.join("hook-ran");
596        let hook_path = hooks_dir.join("post-index-change");
597
598        fs::create_dir_all(&root).expect("create root");
599        fs::write(root.join("MEMORY.md"), "baseline").expect("write memory");
600        reset_git_repository(&root).await.expect("reset repo");
601        fs::create_dir_all(&hooks_dir).expect("create hook dir");
602        fs::write(
603            &hook_path,
604            format!(
605                "#!/bin/sh\nprintf ran > \"{}\"\n",
606                marker_path.to_string_lossy()
607            ),
608        )
609        .expect("write post-index-change hook");
610        let mut permissions = fs::metadata(&hook_path)
611            .expect("read hook metadata")
612            .permissions();
613        permissions.set_mode(0o755);
614        fs::set_permissions(&hook_path, permissions).expect("mark hook executable");
615        git_stdout(
616            &root,
617            &[
618                "config",
619                "core.hooksPath",
620                hooks_dir.to_string_lossy().as_ref(),
621            ],
622        );
623
624        write_index_from_head(&root).expect("rewrite baseline index");
625
626        assert!(
627            !marker_path.exists(),
628            "baseline index writes should not invoke configured hook directories"
629        );
630    }
631
632    #[tokio::test]
633    async fn diff_reports_added_modified_and_deleted_files() {
634        let home = TempDir::new().expect("tempdir");
635        let root = home.path().join("repo");
636        fs::create_dir_all(root.join("rollout_summaries")).expect("create rollout summaries");
637        fs::write(root.join("MEMORY.md"), "old").expect("write memory");
638        fs::write(
639            root.join("rollout_summaries/deleted.md"),
640            "thread_id: 00000000-0000-4000-8000-000000000001\nimportant stale evidence\n",
641        )
642        .expect("write rollout summary");
643        reset_git_repository(&root).await.expect("reset repo");
644
645        fs::write(root.join("MEMORY.md"), "new").expect("update memory");
646        fs::write(root.join("memory_summary.md"), "summary").expect("write summary");
647        fs::remove_file(root.join("rollout_summaries/deleted.md")).expect("delete summary");
648
649        let diff = diff_since_latest_init(&root).await.expect("diff");
650        assert_eq!(
651            diff.changes,
652            vec![
653                GitBaselineChange {
654                    status: GitBaselineChangeStatus::Modified,
655                    path: "MEMORY.md".to_string(),
656                },
657                GitBaselineChange {
658                    status: GitBaselineChangeStatus::Added,
659                    path: "memory_summary.md".to_string(),
660                },
661                GitBaselineChange {
662                    status: GitBaselineChangeStatus::Deleted,
663                    path: "rollout_summaries/deleted.md".to_string(),
664                },
665            ]
666        );
667        assert!(
668            diff.unified_diff
669                .contains("diff --git a/MEMORY.md b/MEMORY.md")
670        );
671        assert!(diff.unified_diff.contains("-old"));
672        assert!(diff.unified_diff.contains("+new"));
673        assert!(
674            diff.unified_diff
675                .contains("diff --git a/memory_summary.md b/memory_summary.md")
676        );
677        assert!(diff.unified_diff.contains("+summary"));
678        assert!(
679            diff.unified_diff.contains(
680                "diff --git a/rollout_summaries/deleted.md b/rollout_summaries/deleted.md"
681            )
682        );
683        assert!(diff.unified_diff.contains("deleted file mode 100644"));
684        assert!(
685            diff.unified_diff
686                .contains("-thread_id: 00000000-0000-4000-8000-000000000001")
687        );
688        assert!(diff.unified_diff.contains("-important stale evidence"));
689    }
690
691    #[tokio::test]
692    async fn reset_drops_previous_history() {
693        let home = TempDir::new().expect("tempdir");
694        let root = home.path().join("repo");
695        fs::create_dir_all(&root).expect("create root");
696        fs::write(root.join("MEMORY.md"), "old").expect("write memory");
697        reset_git_repository(&root).await.expect("reset repo");
698
699        fs::write(root.join("MEMORY.md"), "new").expect("update memory");
700        reset_git_repository(&root).await.expect("reset repo again");
701
702        let repo = gix::open(&root).expect("open repo");
703        let head = repo.head_id().expect("head").detach();
704        let commit = repo.find_commit(head).expect("find head commit");
705        assert_eq!(commit.parent_ids().count(), 0);
706        let diff = diff_since_latest_init(&root).await.expect("diff");
707        assert!(!diff.has_changes());
708    }
709
710    #[tokio::test]
711    async fn status_scan_does_not_write_added_file_blobs() {
712        let home = TempDir::new().expect("tempdir");
713        let root = home.path().join("repo");
714        fs::create_dir_all(&root).expect("create root");
715        reset_git_repository(&root).await.expect("reset repo");
716        let added_content = b"new uncommitted memory";
717        fs::write(root.join("MEMORY.md"), added_content).expect("write memory");
718
719        let diff = diff_since_latest_init(&root).await.expect("diff");
720        assert!(diff.has_changes());
721
722        let repo = gix::open(&root).expect("open repo");
723        let added_oid = blob_oid(&repo, added_content).expect("compute added oid");
724        assert!(
725            repo.find_blob(added_oid).is_err(),
726            "status scans should hash current files without writing loose git objects"
727        );
728    }
729
730    #[cfg(unix)]
731    #[tokio::test]
732    async fn reports_executable_bit_changes_as_modified() {
733        use std::os::unix::fs::PermissionsExt;
734
735        let home = TempDir::new().expect("tempdir");
736        let root = home.path().join("repo");
737        fs::create_dir_all(&root).expect("create root");
738        let path = root.join("MEMORY.md");
739        fs::write(&path, "same content").expect("write memory");
740        reset_git_repository(&root).await.expect("reset repo");
741        let mut permissions = fs::metadata(&path).expect("stat memory").permissions();
742        permissions.set_mode(permissions.mode() | 0o111);
743        fs::set_permissions(&path, permissions).expect("chmod memory");
744
745        let diff = diff_since_latest_init(&root).await.expect("diff");
746        assert_eq!(
747            diff.changes,
748            vec![GitBaselineChange {
749                status: GitBaselineChangeStatus::Modified,
750                path: "MEMORY.md".to_string(),
751            }]
752        );
753        assert!(diff.unified_diff.contains("old mode 100644"));
754        assert!(diff.unified_diff.contains("new mode 100755"));
755    }
756}