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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum GitBaselineChangeStatus {
23 Added,
24 Modified,
25 Deleted,
26}
27
28impl GitBaselineChangeStatus {
29 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#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct GitBaselineChange {
42 pub status: GitBaselineChangeStatus,
43 pub path: String,
44}
45
46#[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
65pub 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
74pub 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
104pub 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, ¤t_entries);
112 let unified_diff =
113 render_unified_diff(&repo, &root, &head_entries, ¤t_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"], 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}