Skip to main content

maw/model/
diff.rs

1//! `PatchSet` computation from working directory diff (§5.4).
2//!
3//! Builds a [`PatchSet`] by comparing a workspace's working directory against
4//! a base epoch commit using `git diff` and `git ls-files`.
5//!
6//! # Overview
7//!
8//! [`compute_patchset`] does three things:
9//!
10//! 1. Runs `git diff --find-renames --name-status <epoch>` in `workspace_path`
11//!    to enumerate tracked changes (added, modified, deleted, renamed files).
12//! 2. Runs `git ls-files --others --exclude-standard` to collect untracked
13//!    files, recording each as an additional [`PatchValue::Add`] entry.
14//! 3. For each change, looks up or computes the relevant blob OID(s) using
15//!    `git hash-object -w` and `git rev-parse <epoch>:<path>`.
16//!
17//! # `FileId` allocation
18//!
19//! File identity is resolved in this order:
20//! 1. `.manifold/fileids` mapping (when present) for stable cross-run IDs.
21//! 2. Deterministic fallback from the epoch blob OID (existing files).
22//! 3. Deterministic fallback from path hash (new files not yet in map).
23//!
24//! # Example flow
25//!
26//! ```text
27//! compute_patchset(repo_root, workspace_path, &epoch)
28//!   ├── git diff --find-renames --name-status <epoch>  → A/M/D/R lines
29//!   ├── git ls-files --others --exclude-standard        → untracked paths
30//!   ├── git hash-object -w <file>                       → new blob OIDs
31//!   └── git rev-parse <epoch>:<path>                    → base blob OIDs
32//! ```
33
34use std::collections::BTreeMap;
35use std::path::{Path, PathBuf};
36use std::process::Command;
37
38use sha2::{Digest, Sha256};
39
40use crate::model::file_id::FileIdMap;
41use crate::model::patch::{FileId, PatchSet, PatchValue};
42use crate::model::types::{EpochId, GitOid};
43
44// ---------------------------------------------------------------------------
45// Error type
46// ---------------------------------------------------------------------------
47
48/// Errors that can occur when computing a [`PatchSet`] from a working dir diff.
49#[derive(Debug)]
50pub enum DiffError {
51    /// A git command failed.
52    GitCommand {
53        /// The full command string (for diagnostics).
54        command: String,
55        /// Stderr from git.
56        stderr: String,
57        /// Process exit code, if available.
58        exit_code: Option<i32>,
59    },
60    /// A git OID returned by a command was malformed.
61    InvalidOid {
62        /// The raw string git printed.
63        raw: String,
64    },
65    /// An I/O error (e.g. spawning git).
66    Io(std::io::Error),
67    /// A line in `git diff --name-status` output was malformed.
68    MalformedDiffLine(String),
69    /// Loading `.manifold/fileids` failed.
70    FileIdMap(String),
71}
72
73impl std::fmt::Display for DiffError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::GitCommand {
77                command,
78                stderr,
79                exit_code,
80            } => {
81                write!(f, "`{command}` failed")?;
82                if let Some(code) = exit_code {
83                    write!(f, " (exit {code})")?;
84                }
85                if !stderr.is_empty() {
86                    write!(f, ": {stderr}")?;
87                }
88                Ok(())
89            }
90            Self::InvalidOid { raw } => write!(f, "invalid git OID: {raw:?}"),
91            Self::Io(e) => write!(f, "I/O error: {e}"),
92            Self::MalformedDiffLine(line) => write!(f, "malformed diff line: {line:?}"),
93            Self::FileIdMap(message) => write!(f, "failed to load FileId map: {message}"),
94        }
95    }
96}
97
98impl std::error::Error for DiffError {
99    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
100        if let Self::Io(e) = self {
101            Some(e)
102        } else {
103            None
104        }
105    }
106}
107
108impl From<std::io::Error> for DiffError {
109    fn from(e: std::io::Error) -> Self {
110        Self::Io(e)
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Internal: parsed diff entry
116// ---------------------------------------------------------------------------
117
118/// A single entry parsed from `git diff --find-renames --name-status <epoch>`.
119#[derive(Debug, PartialEq, Eq)]
120enum DiffEntry {
121    /// File was added (did not exist in epoch).
122    Added(PathBuf),
123    /// File content was changed in place.
124    Modified(PathBuf),
125    /// File was deleted (no longer present in working dir).
126    Deleted(PathBuf),
127    /// File was renamed (and optionally also modified).
128    Renamed { from: PathBuf, to: PathBuf },
129}
130
131// ---------------------------------------------------------------------------
132// Internal: git helpers
133// ---------------------------------------------------------------------------
134
135/// Run a git command in `dir` and return trimmed stdout, or a [`DiffError`].
136fn git_cmd(dir: &Path, args: &[&str]) -> Result<String, DiffError> {
137    let out = Command::new("git").args(args).current_dir(dir).output()?;
138    if out.status.success() {
139        Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_owned())
140    } else {
141        Err(DiffError::GitCommand {
142            command: format!("git {}", args.join(" ")),
143            stderr: String::from_utf8_lossy(&out.stderr).trim().to_owned(),
144            exit_code: out.status.code(),
145        })
146    }
147}
148
149/// Parse `git diff --find-renames --name-status <epoch>` output into [`DiffEntry`]s.
150///
151/// Each non-empty line has the form:
152/// - `A\t<path>` — added
153/// - `M\t<path>` — modified
154/// - `D\t<path>` — deleted
155/// - `R<score>\t<from>\t<to>` — renamed (score is a similarity percentage)
156fn parse_diff_name_status(output: &str) -> Result<Vec<DiffEntry>, DiffError> {
157    let mut entries = Vec::new();
158    for line in output.lines() {
159        if line.is_empty() {
160            continue;
161        }
162        let parts: Vec<&str> = line.splitn(3, '\t').collect();
163        let status = parts.first().copied().unwrap_or("");
164        match status {
165            "A" if parts.len() == 2 => {
166                entries.push(DiffEntry::Added(PathBuf::from(parts[1])));
167            }
168            "M" if parts.len() == 2 => {
169                entries.push(DiffEntry::Modified(PathBuf::from(parts[1])));
170            }
171            "D" if parts.len() == 2 => {
172                entries.push(DiffEntry::Deleted(PathBuf::from(parts[1])));
173            }
174            s if s.starts_with('R') && parts.len() == 3 => {
175                entries.push(DiffEntry::Renamed {
176                    from: PathBuf::from(parts[1]),
177                    to: PathBuf::from(parts[2]),
178                });
179            }
180            _ => {
181                return Err(DiffError::MalformedDiffLine(line.to_owned()));
182            }
183        }
184    }
185    Ok(entries)
186}
187
188/// Hash a file and write it to the git object store, returning its blob OID.
189///
190/// Equivalent to `git hash-object -w -- <abs_path>`.
191fn hash_object_write(workspace_path: &Path, abs_file: &Path) -> Result<GitOid, DiffError> {
192    let path_str = abs_file.to_string_lossy();
193    let stdout = git_cmd(workspace_path, &["hash-object", "-w", "--", &path_str])?;
194    let trimmed = stdout.trim();
195    GitOid::new(trimmed).map_err(|_| DiffError::InvalidOid {
196        raw: trimmed.to_owned(),
197    })
198}
199
200/// Look up the blob OID of `path` in the epoch commit's tree.
201///
202/// Equivalent to `git rev-parse <epoch>:<path>`.
203fn epoch_blob_oid(
204    workspace_path: &Path,
205    epoch: &EpochId,
206    path: &Path,
207) -> Result<GitOid, DiffError> {
208    let rev = format!("{}:{}", epoch.as_str(), path.to_string_lossy());
209    let stdout = git_cmd(workspace_path, &["rev-parse", &rev])?;
210    let trimmed = stdout.trim();
211    GitOid::new(trimmed).map_err(|_| DiffError::InvalidOid {
212        raw: trimmed.to_owned(),
213    })
214}
215
216/// Derive a deterministic fallback [`FileId`] from a file path.
217///
218/// Used when a path is not yet present in `.manifold/fileids`.
219fn file_id_from_path(path: &Path) -> FileId {
220    let mut hasher = Sha256::new();
221    hasher.update(path.to_string_lossy().as_bytes());
222    let digest = hasher.finalize();
223    let mut bytes = [0_u8; 16];
224    bytes.copy_from_slice(&digest[..16]);
225    FileId::new(u128::from_be_bytes(bytes))
226}
227
228/// Derive a deterministic [`FileId`] from an existing blob OID.
229///
230/// Used for pre-existing files (Modify, Delete, Rename) when no `FileId` mapping
231/// is available for the path.
232fn file_id_from_blob(blob: &GitOid) -> FileId {
233    // Parse the first 32 hex characters of the OID as a u128.
234    let hex = &blob.as_str()[..32];
235    // This cannot fail: GitOid is validated to be 40 lowercase hex chars.
236    let n = u128::from_str_radix(hex, 16).unwrap_or(0);
237    FileId::new(n)
238}
239
240fn repo_root_for_workspace(workspace_path: &Path) -> Result<PathBuf, DiffError> {
241    let root = git_cmd(workspace_path, &["rev-parse", "--show-toplevel"])?;
242    Ok(PathBuf::from(root))
243}
244
245fn load_file_id_map(workspace_path: &Path) -> Result<FileIdMap, DiffError> {
246    let repo_root = repo_root_for_workspace(workspace_path)?;
247    let fileids_path = repo_root.join(".manifold").join("fileids");
248    FileIdMap::load(&fileids_path).map_err(|e| DiffError::FileIdMap(e.to_string()))
249}
250
251// ---------------------------------------------------------------------------
252// Public API
253// ---------------------------------------------------------------------------
254
255/// Compute a [`PatchSet`] from a workspace's current working directory state
256/// relative to the given base epoch commit.
257///
258/// # Arguments
259///
260/// - `workspace_path` — absolute path to the workspace working directory.
261/// - `base_epoch` — the epoch commit to diff against (an ancestor of the
262///   workspace's current state).
263///
264/// # What this does
265///
266/// 1. Runs `git diff --find-renames --name-status <epoch>` to detect tracked
267///    changes: added, modified, deleted, and renamed files.
268/// 2. Runs `git ls-files --others --exclude-standard` to collect untracked
269///    files (new files not yet staged), recording them as `Add` entries.
270/// 3. For each change, computes the relevant blob OIDs:
271///    - Working-directory file content → `git hash-object -w`
272///    - Epoch tree blob → `git rev-parse <epoch>:<path>`
273///
274/// # `FileId` note
275///
276/// `FileIds` come from `.manifold/fileids` when available, with deterministic
277/// fallbacks for repositories that do not yet have an identity map.
278///
279/// # Errors
280///
281/// Returns [`DiffError`] if any git command fails or produces unexpected output.
282pub fn compute_patchset(
283    workspace_path: &Path,
284    base_epoch: &EpochId,
285) -> Result<PatchSet, DiffError> {
286    let mut patches: BTreeMap<PathBuf, PatchValue> = BTreeMap::new();
287    let file_id_map = load_file_id_map(workspace_path)?;
288
289    // -----------------------------------------------------------------------
290    // Step 1: tracked changes from git diff
291    // -----------------------------------------------------------------------
292    let diff_out = git_cmd(
293        workspace_path,
294        &[
295            "diff",
296            "--find-renames",
297            "--name-status",
298            base_epoch.as_str(),
299        ],
300    )?;
301
302    let entries = parse_diff_name_status(&diff_out)?;
303
304    for entry in entries {
305        match entry {
306            DiffEntry::Added(path) => {
307                let abs = workspace_path.join(&path);
308                let blob = hash_object_write(workspace_path, &abs)?;
309                let file_id = file_id_map
310                    .id_for_path(&path)
311                    .unwrap_or_else(|| file_id_from_path(&path));
312                patches.insert(path, PatchValue::Add { blob, file_id });
313            }
314            DiffEntry::Modified(path) => {
315                let base_blob = epoch_blob_oid(workspace_path, base_epoch, &path)?;
316                let abs = workspace_path.join(&path);
317                let new_blob = hash_object_write(workspace_path, &abs)?;
318                let file_id = file_id_map
319                    .id_for_path(&path)
320                    .unwrap_or_else(|| file_id_from_blob(&base_blob));
321                patches.insert(
322                    path,
323                    PatchValue::Modify {
324                        base_blob,
325                        new_blob,
326                        file_id,
327                    },
328                );
329            }
330            DiffEntry::Deleted(path) => {
331                let previous_blob = epoch_blob_oid(workspace_path, base_epoch, &path)?;
332                let file_id = file_id_map
333                    .id_for_path(&path)
334                    .unwrap_or_else(|| file_id_from_blob(&previous_blob));
335                patches.insert(
336                    path,
337                    PatchValue::Delete {
338                        previous_blob,
339                        file_id,
340                    },
341                );
342            }
343            DiffEntry::Renamed { from, to } => {
344                let base_blob = epoch_blob_oid(workspace_path, base_epoch, &from)?;
345                let abs_to = workspace_path.join(&to);
346                let new_blob_oid = hash_object_write(workspace_path, &abs_to)?;
347                let file_id = file_id_map
348                    .id_for_path(&from)
349                    .or_else(|| file_id_map.id_for_path(&to))
350                    .unwrap_or_else(|| file_id_from_blob(&base_blob));
351                // Record new_blob only if content changed.
352                let new_blob = if new_blob_oid == base_blob {
353                    None
354                } else {
355                    Some(new_blob_oid)
356                };
357                patches.insert(
358                    to,
359                    PatchValue::Rename {
360                        from,
361                        file_id,
362                        new_blob,
363                    },
364                );
365            }
366        }
367    }
368
369    // -----------------------------------------------------------------------
370    // Step 2: untracked files → Add
371    // -----------------------------------------------------------------------
372    let untracked_out = git_cmd(
373        workspace_path,
374        &["ls-files", "--others", "--exclude-standard"],
375    )?;
376
377    for line in untracked_out.lines() {
378        if line.is_empty() {
379            continue;
380        }
381        let path = PathBuf::from(line);
382        // Skip if already handled via the diff (e.g. a staged Add).
383        if patches.contains_key(&path) {
384            continue;
385        }
386        let abs = workspace_path.join(&path);
387        let blob = hash_object_write(workspace_path, &abs)?;
388        let file_id = file_id_map
389            .id_for_path(&path)
390            .unwrap_or_else(|| file_id_from_path(&path));
391        patches.insert(path, PatchValue::Add { blob, file_id });
392    }
393
394    Ok(PatchSet {
395        base_epoch: base_epoch.clone(),
396        patches,
397    })
398}
399
400// ---------------------------------------------------------------------------
401// Tests
402// ---------------------------------------------------------------------------
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use std::fs;
408
409    // -----------------------------------------------------------------------
410    // Test helpers
411    // -----------------------------------------------------------------------
412
413    /// Initialize a bare-minimum git repo in `dir` for testing.
414    ///
415    /// Configures `user.email` and `user.name` so commits succeed without a
416    /// global git config (common in CI environments).
417    fn git_init(dir: &Path) {
418        run_git(dir, &["init", "-b", "main"]);
419        run_git(dir, &["config", "user.email", "test@test.com"]);
420        run_git(dir, &["config", "user.name", "Test"]);
421        run_git(dir, &["config", "commit.gpgsign", "false"]);
422    }
423
424    /// Run a git command in `dir`, panicking on failure (test helper only).
425    fn run_git(dir: &Path, args: &[&str]) -> String {
426        let out = Command::new("git")
427            .args(args)
428            .current_dir(dir)
429            .output()
430            .expect("git must be installed");
431        if !out.status.success() {
432            let stderr = String::from_utf8_lossy(&out.stderr);
433            panic!("git {} failed: {}", args.join(" "), stderr);
434        }
435        String::from_utf8_lossy(&out.stdout).trim().to_owned()
436    }
437
438    /// Write `content` to `dir/path`, creating parent directories as needed.
439    fn write_file(dir: &Path, path: &str, content: &str) {
440        let full = dir.join(path);
441        if let Some(parent) = full.parent() {
442            fs::create_dir_all(parent).unwrap();
443        }
444        fs::write(full, content).unwrap();
445    }
446
447    /// Create an initial epoch commit in `dir` and return its OID.
448    fn make_epoch(dir: &Path, files: &[(&str, &str)]) -> EpochId {
449        for (path, content) in files {
450            write_file(dir, path, content);
451        }
452        run_git(dir, &["add", "."]);
453        run_git(dir, &["commit", "-m", "epoch"]);
454        let oid = run_git(dir, &["rev-parse", "HEAD"]);
455        EpochId::new(&oid).expect("HEAD OID must be valid")
456    }
457
458    // -----------------------------------------------------------------------
459    // parse_diff_name_status unit tests
460    // -----------------------------------------------------------------------
461
462    #[test]
463    fn parse_added_line() {
464        let input = "A\tsrc/new.rs";
465        let entries = parse_diff_name_status(input).unwrap();
466        assert_eq!(entries.len(), 1);
467        assert_eq!(entries[0], DiffEntry::Added(PathBuf::from("src/new.rs")));
468    }
469
470    #[test]
471    fn parse_modified_line() {
472        let input = "M\tsrc/lib.rs";
473        let entries = parse_diff_name_status(input).unwrap();
474        assert_eq!(entries.len(), 1);
475        assert_eq!(entries[0], DiffEntry::Modified(PathBuf::from("src/lib.rs")));
476    }
477
478    #[test]
479    fn parse_deleted_line() {
480        let input = "D\told.rs";
481        let entries = parse_diff_name_status(input).unwrap();
482        assert_eq!(entries.len(), 1);
483        assert_eq!(entries[0], DiffEntry::Deleted(PathBuf::from("old.rs")));
484    }
485
486    #[test]
487    fn parse_renamed_line() {
488        let input = "R90\told/path.rs\tnew/path.rs";
489        let entries = parse_diff_name_status(input).unwrap();
490        assert_eq!(entries.len(), 1);
491        assert_eq!(
492            entries[0],
493            DiffEntry::Renamed {
494                from: PathBuf::from("old/path.rs"),
495                to: PathBuf::from("new/path.rs"),
496            }
497        );
498    }
499
500    #[test]
501    fn parse_renamed_r100() {
502        // R100 = identical content, just moved
503        let input = "R100\tfoo.rs\tbar.rs";
504        let entries = parse_diff_name_status(input).unwrap();
505        assert_eq!(
506            entries[0],
507            DiffEntry::Renamed {
508                from: PathBuf::from("foo.rs"),
509                to: PathBuf::from("bar.rs"),
510            }
511        );
512    }
513
514    #[test]
515    fn parse_empty_output() {
516        let entries = parse_diff_name_status("").unwrap();
517        assert!(entries.is_empty());
518    }
519
520    #[test]
521    fn parse_multiple_entries() {
522        let input = "A\tnew.rs\nM\told.rs\nD\tgone.rs";
523        let entries = parse_diff_name_status(input).unwrap();
524        assert_eq!(entries.len(), 3);
525    }
526
527    #[test]
528    fn parse_malformed_line_returns_error() {
529        let input = "Z\tunknown_status";
530        let result = parse_diff_name_status(input);
531        assert!(result.is_err());
532    }
533
534    // -----------------------------------------------------------------------
535    // file_id_from_path / file_id_from_blob
536    // -----------------------------------------------------------------------
537
538    #[test]
539    fn file_id_from_path_is_deterministic() {
540        let path = Path::new("src/lib.rs");
541        let id1 = file_id_from_path(path);
542        let id2 = file_id_from_path(path);
543        assert_eq!(id1, id2);
544    }
545
546    #[test]
547    fn file_id_from_path_differs_for_different_paths() {
548        let id1 = file_id_from_path(Path::new("src/a.rs"));
549        let id2 = file_id_from_path(Path::new("src/b.rs"));
550        assert_ne!(id1, id2);
551    }
552
553    // -----------------------------------------------------------------------
554    // file_id_from_blob
555    // -----------------------------------------------------------------------
556
557    #[test]
558    fn file_id_from_blob_is_deterministic() {
559        let oid = GitOid::new(&"a".repeat(40)).unwrap();
560        let id1 = file_id_from_blob(&oid);
561        let id2 = file_id_from_blob(&oid);
562        assert_eq!(id1, id2);
563    }
564
565    #[test]
566    fn file_id_from_blob_differs_for_different_blobs() {
567        let oid1 = GitOid::new(&"a".repeat(40)).unwrap();
568        let oid2 = GitOid::new(&"b".repeat(40)).unwrap();
569        assert_ne!(file_id_from_blob(&oid1), file_id_from_blob(&oid2));
570    }
571
572    // -----------------------------------------------------------------------
573    // Integration tests: compute_patchset with a real git repo
574    // -----------------------------------------------------------------------
575
576    #[test]
577    fn compute_patchset_empty_working_dir() {
578        let dir = tempfile::tempdir().unwrap();
579        let root = dir.path();
580
581        git_init(root);
582        write_file(root, "existing.rs", "fn main() {}");
583        run_git(root, &["add", "."]);
584        run_git(root, &["commit", "-m", "epoch"]);
585        let oid = run_git(root, &["rev-parse", "HEAD"]);
586        let epoch = EpochId::new(&oid).unwrap();
587
588        // No changes since epoch.
589        let ps = compute_patchset(root, &epoch).unwrap();
590        assert!(ps.is_empty(), "no changes → empty PatchSet");
591        assert_eq!(ps.base_epoch, epoch);
592    }
593
594    #[test]
595    fn compute_patchset_added_file() {
596        let dir = tempfile::tempdir().unwrap();
597        let root = dir.path();
598
599        git_init(root);
600        let epoch = make_epoch(root, &[("base.rs", "// base")]);
601
602        // Stage a new file.
603        write_file(root, "new.rs", "fn new() {}");
604        run_git(root, &["add", "new.rs"]);
605
606        let ps = compute_patchset(root, &epoch).unwrap();
607        assert_eq!(ps.len(), 1);
608
609        let pv = ps
610            .patches
611            .get(&PathBuf::from("new.rs"))
612            .expect("new.rs in PatchSet");
613        assert!(
614            matches!(pv, PatchValue::Add { .. }),
615            "expected Add, got {pv:?}"
616        );
617        if let PatchValue::Add { blob, .. } = pv {
618            // Verify OID is valid (40 hex chars).
619            assert_eq!(blob.as_str().len(), 40);
620        }
621    }
622
623    #[test]
624    fn compute_patchset_untracked_file() {
625        let dir = tempfile::tempdir().unwrap();
626        let root = dir.path();
627
628        git_init(root);
629        let epoch = make_epoch(root, &[("base.rs", "// base")]);
630
631        // Do NOT stage — should be detected via ls-files --others.
632        write_file(root, "untracked.txt", "hello");
633
634        let ps = compute_patchset(root, &epoch).unwrap();
635        assert_eq!(ps.len(), 1);
636
637        let pv = ps
638            .patches
639            .get(&PathBuf::from("untracked.txt"))
640            .expect("untracked.txt in PatchSet");
641        assert!(matches!(pv, PatchValue::Add { .. }));
642    }
643
644    #[test]
645    fn compute_patchset_modified_file() {
646        let dir = tempfile::tempdir().unwrap();
647        let root = dir.path();
648
649        git_init(root);
650        let epoch = make_epoch(root, &[("lib.rs", "fn original() {}")]);
651
652        // Modify and stage.
653        write_file(root, "lib.rs", "fn modified() {}");
654        run_git(root, &["add", "lib.rs"]);
655
656        let ps = compute_patchset(root, &epoch).unwrap();
657        assert_eq!(ps.len(), 1);
658
659        let pv = ps
660            .patches
661            .get(&PathBuf::from("lib.rs"))
662            .expect("lib.rs in PatchSet");
663        assert!(
664            matches!(pv, PatchValue::Modify { .. }),
665            "expected Modify, got {pv:?}"
666        );
667        if let PatchValue::Modify {
668            base_blob,
669            new_blob,
670            ..
671        } = pv
672        {
673            // base_blob is the epoch's blob, new_blob is the current content.
674            assert_ne!(base_blob, new_blob, "blobs must differ after modification");
675            assert_eq!(base_blob.as_str().len(), 40);
676            assert_eq!(new_blob.as_str().len(), 40);
677        }
678    }
679
680    #[test]
681    fn compute_patchset_deleted_file() {
682        let dir = tempfile::tempdir().unwrap();
683        let root = dir.path();
684
685        git_init(root);
686        let epoch = make_epoch(root, &[("to_delete.rs", "fn gone() {}")]);
687
688        // Delete and stage.
689        run_git(root, &["rm", "to_delete.rs"]);
690
691        let ps = compute_patchset(root, &epoch).unwrap();
692        assert_eq!(ps.len(), 1);
693
694        let pv = ps
695            .patches
696            .get(&PathBuf::from("to_delete.rs"))
697            .expect("to_delete.rs in PatchSet");
698        assert!(
699            matches!(pv, PatchValue::Delete { .. }),
700            "expected Delete, got {pv:?}"
701        );
702        if let PatchValue::Delete { previous_blob, .. } = pv {
703            assert_eq!(previous_blob.as_str().len(), 40);
704        }
705    }
706
707    #[test]
708    fn compute_patchset_renamed_file_same_content() {
709        let dir = tempfile::tempdir().unwrap();
710        let root = dir.path();
711
712        git_init(root);
713        // Use content long enough for git to detect the rename.
714        let content = "fn example() { println!(\"hello world\"); }\n".repeat(5);
715        let epoch = make_epoch(root, &[("old_name.rs", &content)]);
716
717        // Rename without modifying content.
718        run_git(root, &["mv", "old_name.rs", "new_name.rs"]);
719
720        let ps = compute_patchset(root, &epoch).unwrap();
721        assert_eq!(ps.len(), 1, "rename → one entry at destination path");
722
723        let pv = ps
724            .patches
725            .get(&PathBuf::from("new_name.rs"))
726            .expect("new_name.rs in PatchSet");
727        assert!(
728            matches!(pv, PatchValue::Rename { .. }),
729            "expected Rename, got {pv:?}"
730        );
731        if let PatchValue::Rename { from, new_blob, .. } = pv {
732            assert_eq!(from, &PathBuf::from("old_name.rs"));
733            assert!(
734                new_blob.is_none(),
735                "content unchanged → new_blob should be None"
736            );
737        }
738    }
739
740    #[test]
741    fn compute_patchset_renamed_file_with_content_change() {
742        let dir = tempfile::tempdir().unwrap();
743        let root = dir.path();
744
745        git_init(root);
746        // Use content long enough for git to detect the rename.
747        let content = "fn example() { println!(\"original content\"); }\n".repeat(5);
748        let epoch = make_epoch(root, &[("old.rs", &content)]);
749
750        // Rename and modify content.
751        run_git(root, &["mv", "old.rs", "new.rs"]);
752        write_file(root, "new.rs", &format!("{content}// modified\n"));
753        run_git(root, &["add", "new.rs"]);
754
755        let ps = compute_patchset(root, &epoch).unwrap();
756        assert_eq!(ps.len(), 1);
757
758        let pv = ps
759            .patches
760            .get(&PathBuf::from("new.rs"))
761            .expect("new.rs in PatchSet");
762        assert!(
763            matches!(pv, PatchValue::Rename { .. }),
764            "expected Rename, got {pv:?}"
765        );
766        if let PatchValue::Rename { from, new_blob, .. } = pv {
767            assert_eq!(from, &PathBuf::from("old.rs"));
768            assert!(
769                new_blob.is_some(),
770                "content changed → new_blob should be Some"
771            );
772        }
773    }
774
775    #[test]
776    fn compute_patchset_multiple_changes() {
777        let dir = tempfile::tempdir().unwrap();
778        let root = dir.path();
779
780        git_init(root);
781        let epoch = make_epoch(
782            root,
783            &[
784                ("keep.rs", "fn keep() {}"),
785                ("modify.rs", "fn modify() {}"),
786                ("delete.rs", "fn delete() {}"),
787            ],
788        );
789
790        // Apply multiple changes.
791        write_file(root, "add.rs", "fn add() {}"); // new untracked
792        write_file(root, "modify.rs", "fn modified() {}"); // changed
793        run_git(root, &["rm", "delete.rs"]); // deleted
794        run_git(root, &["add", "."]);
795
796        let ps = compute_patchset(root, &epoch).unwrap();
797
798        // keep.rs → no entry
799        assert!(!ps.patches.contains_key(&PathBuf::from("keep.rs")));
800
801        // add.rs → Add
802        assert!(matches!(
803            ps.patches.get(&PathBuf::from("add.rs")),
804            Some(PatchValue::Add { .. })
805        ));
806
807        // modify.rs → Modify
808        assert!(matches!(
809            ps.patches.get(&PathBuf::from("modify.rs")),
810            Some(PatchValue::Modify { .. })
811        ));
812
813        // delete.rs → Delete
814        assert!(matches!(
815            ps.patches.get(&PathBuf::from("delete.rs")),
816            Some(PatchValue::Delete { .. })
817        ));
818
819        assert_eq!(ps.len(), 3);
820    }
821
822    #[test]
823    fn compute_patchset_blob_oids_are_correct() {
824        let dir = tempfile::tempdir().unwrap();
825        let root = dir.path();
826
827        git_init(root);
828        let epoch_id = make_epoch(root, &[("file.rs", "original")]);
829
830        // Get epoch blob OID directly.
831        let expected_base_blob = run_git(
832            root,
833            &["rev-parse", &format!("{}:file.rs", epoch_id.as_str())],
834        );
835
836        write_file(root, "file.rs", "modified");
837        run_git(root, &["add", "file.rs"]);
838
839        // Get expected new blob OID.
840        let expected_new_blob = run_git(root, &["ls-files", "--cached", "-s", "file.rs"]);
841        // ls-files -s output: "<mode> <blob> <stage>\t<path>"
842        let expected_new_oid: String = expected_new_blob
843            .split_whitespace()
844            .nth(1)
845            .unwrap_or("")
846            .to_owned();
847
848        let ps = compute_patchset(root, &epoch_id).unwrap();
849        if let Some(PatchValue::Modify {
850            base_blob,
851            new_blob,
852            ..
853        }) = ps.patches.get(&PathBuf::from("file.rs"))
854        {
855            assert_eq!(
856                base_blob.as_str(),
857                expected_base_blob,
858                "base_blob must match epoch blob"
859            );
860            assert_eq!(
861                new_blob.as_str(),
862                expected_new_oid,
863                "new_blob must match staged blob"
864            );
865        } else {
866            panic!("expected Modify for file.rs");
867        }
868    }
869
870    #[test]
871    fn compute_patchset_base_epoch_preserved() {
872        let dir = tempfile::tempdir().unwrap();
873        let root = dir.path();
874
875        git_init(root);
876        let epoch = make_epoch(root, &[("a.rs", "content")]);
877
878        write_file(root, "b.rs", "new");
879        run_git(root, &["add", "b.rs"]);
880
881        let ps = compute_patchset(root, &epoch).unwrap();
882        assert_eq!(
883            ps.base_epoch, epoch,
884            "base_epoch must match the epoch passed in"
885        );
886    }
887
888    #[test]
889    fn compute_patchset_uses_btreemap_ordering() {
890        let dir = tempfile::tempdir().unwrap();
891        let root = dir.path();
892
893        git_init(root);
894        let epoch = make_epoch(root, &[("baseline.rs", "x")]);
895
896        // Add files that would sort differently by insertion order vs alpha.
897        write_file(root, "z.rs", "z");
898        write_file(root, "a.rs", "a");
899        write_file(root, "m.rs", "m");
900        run_git(root, &["add", "."]);
901
902        let ps = compute_patchset(root, &epoch).unwrap();
903
904        let keys: Vec<_> = ps.patches.keys().collect();
905        let mut sorted = keys.clone();
906        sorted.sort();
907        assert_eq!(keys, sorted, "PatchSet paths must be in sorted order");
908    }
909
910    #[test]
911    fn compute_patchset_uses_fileid_map_for_modify() {
912        let dir = tempfile::tempdir().unwrap();
913        let root = dir.path();
914
915        git_init(root);
916        let epoch = make_epoch(
917            root,
918            &[(".gitignore", ".manifold/\n"), ("tracked.rs", "v1")],
919        );
920
921        let fileids_dir = root.join(".manifold");
922        fs::create_dir_all(&fileids_dir).unwrap();
923        fs::write(
924            fileids_dir.join("fileids"),
925            r#"[
926  {"path": "tracked.rs", "file_id": "0000000000000000000000000000002a"}
927]"#,
928        )
929        .unwrap();
930
931        write_file(root, "tracked.rs", "v2");
932        run_git(root, &["add", "tracked.rs"]);
933
934        let ps = compute_patchset(root, &epoch).unwrap();
935        let patch = ps.patches.get(&PathBuf::from("tracked.rs")).unwrap();
936        let PatchValue::Modify { file_id, .. } = patch else {
937            panic!("expected Modify patch");
938        };
939        assert_eq!(*file_id, FileId::new(42));
940    }
941
942    #[test]
943    fn compute_patchset_add_file_id_is_deterministic_across_calls() {
944        let dir = tempfile::tempdir().unwrap();
945        let root = dir.path();
946
947        git_init(root);
948        let epoch = make_epoch(root, &[("base.rs", "base")]);
949
950        write_file(root, "new_file.rs", "new content");
951        run_git(root, &["add", "new_file.rs"]);
952
953        let ps1 = compute_patchset(root, &epoch).unwrap();
954        let ps2 = compute_patchset(root, &epoch).unwrap();
955
956        let PatchValue::Add { file_id: id1, .. } =
957            ps1.patches.get(&PathBuf::from("new_file.rs")).unwrap()
958        else {
959            panic!("expected Add patch in first call");
960        };
961        let PatchValue::Add { file_id: id2, .. } =
962            ps2.patches.get(&PathBuf::from("new_file.rs")).unwrap()
963        else {
964            panic!("expected Add patch in second call");
965        };
966
967        assert_eq!(id1, id2);
968    }
969}