Skip to main content

maw/merge/
build.rs

1//! BUILD step of the N-way merge pipeline.
2//!
3//! Takes the epoch commit and a list of resolved file changes, then produces
4//! a new git tree object and commit. This is Step 4 in the
5//! collect → partition → resolve → **build** pipeline.
6//!
7//! # Algorithm
8//!
9//! 1. Read the epoch's full flat file tree via `git ls-tree -r`.
10//! 2. Apply resolved changes:
11//!    - Upsert (add/modify): write a new blob via `git hash-object -w --stdin`,
12//!      update the flat tree map.
13//!    - Delete: remove the path from the flat tree map.
14//! 3. Reconstruct the git tree hierarchy bottom-up, one directory at a time,
15//!    using `git mktree`.
16//! 4. Create the merge commit via `git commit-tree -p <epoch> -m <message>`.
17//!
18//! # Determinism
19//!
20//! Paths are processed in lexicographic order throughout. The same epoch +
21//! resolved changes always produce the same tree OID (blob content and tree
22//! structure are identical), which git's content-addressable storage makes
23//! unique.
24
25#![allow(clippy::missing_errors_doc)]
26
27use std::collections::{BTreeMap, HashMap};
28use std::io::Write as IoWrite;
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use std::time::{SystemTime, UNIX_EPOCH};
32
33use crate::model::types::{EpochId, GitOid, WorkspaceId};
34
35// ---------------------------------------------------------------------------
36// ResolvedChange
37// ---------------------------------------------------------------------------
38
39/// A resolved file change produced by the merge engine's resolve step.
40///
41/// After the partition and resolution phase, each touched path results in
42/// exactly one `ResolvedChange`. The build step applies these changes to the
43/// epoch tree to produce the merged tree.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub enum ResolvedChange {
46    /// File was added or modified; `content` is the new file bytes.
47    ///
48    /// Used for both `Added` and `Modified` changes. The previous content
49    /// (if any) is discarded; `content` becomes the new blob.
50    Upsert {
51        /// Path relative to the repo root.
52        path: PathBuf,
53        /// New file content (bytes).
54        content: Vec<u8>,
55    },
56    /// File was deleted; the path is removed from the merged tree.
57    Delete {
58        /// Path relative to the repo root.
59        path: PathBuf,
60    },
61}
62
63impl ResolvedChange {
64    /// Return the path this change applies to.
65    #[must_use]
66    pub const fn path(&self) -> &PathBuf {
67        match self {
68            Self::Upsert { path, .. } | Self::Delete { path } => path,
69        }
70    }
71
72    /// Return `true` if this is an upsert (add or modify).
73    #[must_use]
74    pub const fn is_upsert(&self) -> bool {
75        matches!(self, Self::Upsert { .. })
76    }
77
78    /// Return `true` if this is a deletion.
79    #[must_use]
80    pub const fn is_delete(&self) -> bool {
81        matches!(self, Self::Delete { .. })
82    }
83}
84
85// ---------------------------------------------------------------------------
86// BuildError
87// ---------------------------------------------------------------------------
88
89/// Errors that can occur during the BUILD step.
90#[derive(Debug)]
91pub enum BuildError {
92    /// A git command failed (non-zero exit).
93    GitCommand {
94        /// The command that was run.
95        command: String,
96        /// Stderr output (trimmed).
97        stderr: String,
98        /// Process exit code if available.
99        exit_code: Option<i32>,
100    },
101    /// An I/O error occurred.
102    Io(std::io::Error),
103    /// A line from `git ls-tree` could not be parsed.
104    MalformedLsTree {
105        /// The raw line that could not be parsed.
106        line: String,
107    },
108    /// A git OID returned by a command was invalid.
109    InvalidOid {
110        /// Human-readable context (e.g., "hash-object output").
111        context: String,
112        /// The raw value returned.
113        raw: String,
114    },
115}
116
117impl std::fmt::Display for BuildError {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            Self::GitCommand {
121                command,
122                stderr,
123                exit_code,
124            } => {
125                write!(f, "`{command}` failed")?;
126                if let Some(code) = exit_code {
127                    write!(f, " (exit {code})")?;
128                }
129                if !stderr.is_empty() {
130                    write!(f, ": {stderr}")?;
131                }
132                Ok(())
133            }
134            Self::Io(e) => write!(f, "I/O error: {e}"),
135            Self::MalformedLsTree { line } => {
136                write!(f, "malformed `git ls-tree` output: {line:?}")
137            }
138            Self::InvalidOid { context, raw } => {
139                write!(f, "invalid OID from {context}: {raw:?}")
140            }
141        }
142    }
143}
144
145impl std::error::Error for BuildError {
146    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
147        if let Self::Io(e) = self {
148            Some(e)
149        } else {
150            None
151        }
152    }
153}
154
155impl From<std::io::Error> for BuildError {
156    fn from(e: std::io::Error) -> Self {
157        Self::Io(e)
158    }
159}
160
161// ---------------------------------------------------------------------------
162// Public API
163// ---------------------------------------------------------------------------
164
165/// Build a merge commit from an epoch and resolved changes.
166///
167/// This is the primary entry point for the BUILD step. It:
168/// 1. Reads the epoch's tree to get the baseline file set.
169/// 2. Applies `resolved` changes (writing new blobs as needed).
170/// 3. Reconstructs the git tree hierarchy using `git mktree`.
171/// 4. Creates and returns the OID of a new commit with `epoch` as its parent.
172///
173/// # Arguments
174///
175/// * `root` — Repository root (where `.git` lives).
176/// * `epoch` — The epoch commit that all workspaces were based on.
177/// * `workspace_ids` — IDs of the workspaces that contributed to this merge
178///   (used to construct the commit message).
179/// * `resolved` — Resolved file changes to apply to the epoch tree.
180/// * `message` — Optional custom commit message. If `None`, a default message
181///   is generated from `workspace_ids`.
182///
183/// # Returns
184///
185/// The OID of the new merge commit. Pass this to [`crate::merge::commit`]
186/// to atomically advance `refs/manifold/epoch/current` and the branch ref.
187///
188/// # Determinism
189///
190/// Given the same `epoch`, `workspace_ids`, and `resolved` inputs (in any
191/// order -- this function sorts them internally), the output tree OID is
192/// always the same. This follows from git's content-addressable storage:
193/// identical tree content produces an identical tree OID. Commit OIDs will
194/// vary because they include a real timestamp.
195pub fn build_merge_commit(
196    root: &Path,
197    epoch: &EpochId,
198    workspace_ids: &[WorkspaceId],
199    resolved: &[ResolvedChange],
200    message: Option<&str>,
201) -> Result<GitOid, BuildError> {
202    // Step 1: Read the epoch tree into a flat map path -> (mode, blob_oid).
203    let mut tree = read_epoch_tree(root, epoch)?;
204
205    // Step 2: Apply resolved changes (sorted for determinism).
206    let mut sorted = resolved.to_vec();
207    sorted.sort_by(|a, b| a.path().cmp(b.path()));
208
209    for change in &sorted {
210        match change {
211            ResolvedChange::Upsert { path, content } => {
212                let blob_oid = write_blob(root, content)?;
213                // Regular file mode. We preserve original mode if the file
214                // already exists in the tree; otherwise use 100644.
215                let mode = tree
216                    .get(path)
217                    .map_or_else(|| "100644".to_owned(), |(m, _)| m.clone());
218                tree.insert(path.clone(), (mode, blob_oid.as_str().to_owned()));
219            }
220            ResolvedChange::Delete { path } => {
221                tree.remove(path);
222            }
223        }
224    }
225
226    // Step 3: Build git tree objects bottom-up.
227    let root_tree_oid = build_tree(root, &tree)?;
228
229    // Step 4: Build commit message.
230    let commit_msg = message.map_or_else(
231        || {
232            let mut ws_names: Vec<&str> = workspace_ids.iter().map(WorkspaceId::as_str).collect();
233            ws_names.sort_unstable(); // deterministic order
234            if ws_names.is_empty() {
235                "epoch: merge".to_owned()
236            } else {
237                format!("epoch: merge {}", ws_names.join(" "))
238            }
239        },
240        str::to_owned,
241    );
242
243    // Step 5: Create the commit.
244    let commit_oid = create_commit(root, epoch, &root_tree_oid, &commit_msg)?;
245
246    Ok(commit_oid)
247}
248
249// ---------------------------------------------------------------------------
250// Internal helpers
251// ---------------------------------------------------------------------------
252
253/// A flat representation of a git tree: path → (mode, `blob_oid`).
254///
255/// Using `BTreeMap` ensures lexicographic ordering when we iterate,
256/// which is required for deterministic tree building.
257type FlatTree = BTreeMap<PathBuf, (String, String)>;
258
259/// Read the epoch's flat tree using `git ls-tree -r <epoch>`.
260///
261/// Returns a `BTreeMap` from relative path to `(mode, blob_oid)`.
262fn read_epoch_tree(root: &Path, epoch: &EpochId) -> Result<FlatTree, BuildError> {
263    // `-r` recurses into subtrees, giving a fully flat list of blobs.
264    // `--full-tree` ensures paths are always relative to the repo root.
265    // `--long` is not used — we only need mode, type, OID, and path.
266    let output = Command::new("git")
267        .args(["ls-tree", "-r", "--full-tree", epoch.as_str()])
268        .current_dir(root)
269        .output()?;
270
271    if !output.status.success() {
272        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
273        return Err(BuildError::GitCommand {
274            command: format!("git ls-tree -r --full-tree {}", epoch.as_str()),
275            stderr,
276            exit_code: output.status.code(),
277        });
278    }
279
280    let raw = String::from_utf8_lossy(&output.stdout);
281    let mut tree = FlatTree::new();
282
283    for line in raw.lines() {
284        let line = line.trim();
285        if line.is_empty() {
286            continue;
287        }
288
289        // Format: "<mode> blob <oid>\t<path>"
290        // We only include blobs (ignore tree entries — they appear in -r
291        // output as recursive entries, but we only want blobs).
292        let (meta, path_str) =
293            line.split_once('\t')
294                .ok_or_else(|| BuildError::MalformedLsTree {
295                    line: line.to_owned(),
296                })?;
297
298        let parts: Vec<&str> = meta.split_whitespace().collect();
299        if parts.len() < 3 {
300            return Err(BuildError::MalformedLsTree {
301                line: line.to_owned(),
302            });
303        }
304
305        let mode = parts[0].to_owned();
306        let obj_type = parts[1];
307        let oid = parts[2].to_owned();
308
309        // Skip tree entries (they are synthesized during build_tree).
310        if obj_type != "blob" {
311            continue;
312        }
313
314        tree.insert(PathBuf::from(path_str), (mode, oid));
315    }
316
317    Ok(tree)
318}
319
320/// Write a blob object to the git object store.
321///
322/// Pipes `content` into `git hash-object -w --stdin` and returns the OID.
323fn write_blob(root: &Path, content: &[u8]) -> Result<GitOid, BuildError> {
324    let mut child = Command::new("git")
325        .args(["hash-object", "-w", "--stdin"])
326        .current_dir(root)
327        .stdin(Stdio::piped())
328        .stdout(Stdio::piped())
329        .stderr(Stdio::piped())
330        .spawn()?;
331
332    // Write content to stdin.
333    if let Some(mut stdin) = child.stdin.take() {
334        stdin.write_all(content).map_err(BuildError::Io)?;
335        // stdin is dropped here, closing the pipe.
336    }
337
338    let output = child.wait_with_output()?;
339
340    if !output.status.success() {
341        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
342        return Err(BuildError::GitCommand {
343            command: "git hash-object -w --stdin".to_owned(),
344            stderr,
345            exit_code: output.status.code(),
346        });
347    }
348
349    let raw = String::from_utf8_lossy(&output.stdout).trim().to_owned();
350    GitOid::new(&raw).map_err(|_| BuildError::InvalidOid {
351        context: "hash-object output".to_owned(),
352        raw,
353    })
354}
355
356/// Build the full git tree hierarchy from a flat path → (mode, oid) map.
357///
358/// `git mktree` builds a single tree level from its stdin. For nested paths,
359/// we must build bottom-up: deepest subtrees first, then include the
360/// resulting tree OIDs as entries in their parents.
361///
362/// Returns the root tree OID.
363fn build_tree(root: &Path, flat: &FlatTree) -> Result<GitOid, BuildError> {
364    // Group blobs by their parent directory path.
365    // `dir_blobs[dir_path]` = list of (mode, "blob", oid, name) for direct children.
366    // `dir_trees[dir_path]` = list of (mode, "tree", oid, name) for subtrees.
367    // We use a HashMap<PathBuf, Vec<_>> where the key is the parent directory.
368
369    // Collect all unique directory paths (excluding root "").
370    let mut all_dirs: Vec<PathBuf> = vec![PathBuf::new()]; // root = empty path
371
372    for path in flat.keys() {
373        let mut current = path.parent().map(PathBuf::from).unwrap_or_default();
374        loop {
375            if current == PathBuf::new() || all_dirs.contains(&current) {
376                break;
377            }
378            all_dirs.push(current.clone());
379            current = current.parent().map(PathBuf::from).unwrap_or_default();
380        }
381    }
382
383    // Sort directories: deepest first (longest path first) so we build bottom-up.
384    all_dirs.sort_by(|a, b| {
385        let a_depth = a.components().count();
386        let b_depth = b.components().count();
387        b_depth.cmp(&a_depth).then(a.cmp(b))
388    });
389
390    // Map from directory path → its tree OID (filled in as we process bottom-up).
391    let mut tree_oids: HashMap<PathBuf, String> = HashMap::new();
392
393    for dir in &all_dirs {
394        // Collect direct-child blob entries for this directory.
395        let mut entries: Vec<String> = Vec::new();
396
397        // Blob entries: files directly under `dir`.
398        for (path, (mode, oid)) in flat {
399            let parent = path.parent().map(PathBuf::from).unwrap_or_default();
400            if &parent == dir {
401                let name = path
402                    .file_name()
403                    .and_then(|n| n.to_str())
404                    .unwrap_or_default();
405                entries.push(format!("{mode} blob {oid}\t{name}"));
406            }
407        }
408
409        // Tree entries: subdirectories directly under `dir`.
410        for (sub_path, sub_oid) in &tree_oids {
411            let parent = sub_path.parent().map(PathBuf::from).unwrap_or_default();
412            if &parent == dir {
413                let name = sub_path
414                    .file_name()
415                    .and_then(|n| n.to_str())
416                    .unwrap_or_default();
417                entries.push(format!("040000 tree {sub_oid}\t{name}"));
418            }
419        }
420
421        // Sort entries for determinism.
422        entries.sort();
423
424        // Build this tree level using `git mktree`.
425        let mktree_input = entries.join("\n") + if entries.is_empty() { "" } else { "\n" };
426        let tree_oid = run_mktree(root, &mktree_input)?;
427        tree_oids.insert(dir.clone(), tree_oid.as_str().to_owned());
428    }
429
430    // The root directory (PathBuf::new()) holds the root tree OID.
431    let root_oid_str = tree_oids.get(&PathBuf::new()).cloned().unwrap_or_default();
432
433    GitOid::new(&root_oid_str).map_err(|_| BuildError::InvalidOid {
434        context: "root tree OID from mktree".to_owned(),
435        raw: root_oid_str,
436    })
437}
438
439/// Run `git mktree` with the given stdin input and return the tree OID.
440fn run_mktree(root: &Path, input: &str) -> Result<GitOid, BuildError> {
441    let mut child = Command::new("git")
442        .args(["mktree"])
443        .current_dir(root)
444        .stdin(Stdio::piped())
445        .stdout(Stdio::piped())
446        .stderr(Stdio::piped())
447        .spawn()?;
448
449    if let Some(mut stdin) = child.stdin.take() {
450        stdin.write_all(input.as_bytes()).map_err(BuildError::Io)?;
451    }
452
453    let output = child.wait_with_output()?;
454
455    if !output.status.success() {
456        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
457        return Err(BuildError::GitCommand {
458            command: "git mktree".to_owned(),
459            stderr,
460            exit_code: output.status.code(),
461        });
462    }
463
464    let raw = String::from_utf8_lossy(&output.stdout).trim().to_owned();
465    GitOid::new(&raw).map_err(|_| BuildError::InvalidOid {
466        context: "git mktree output".to_owned(),
467        raw,
468    })
469}
470
471/// Create a git commit object.
472///
473/// Uses `git commit-tree <tree-oid> -p <parent-oid> -m <message>`.
474/// Sets `GIT_AUTHOR_DATE` and `GIT_COMMITTER_DATE` to the current real time
475/// so that merge commits carry accurate timestamps.
476///
477/// # Note on identity
478///
479/// `git commit-tree` uses the repo's `user.name` and `user.email` config for
480/// authorship.
481///
482/// # Determinism
483///
484/// Tree content is still fully deterministic (same inputs produce the same
485/// tree OID). Commit OIDs will vary because they include the real timestamp,
486/// but this is the expected behavior for merge commits that should reflect
487/// when they actually occurred.
488fn create_commit(
489    root: &Path,
490    parent: &EpochId,
491    tree: &GitOid,
492    message: &str,
493) -> Result<GitOid, BuildError> {
494    let now_secs = SystemTime::now()
495        .duration_since(UNIX_EPOCH)
496        .unwrap_or_default()
497        .as_secs();
498    let timestamp = format!("{now_secs} +0000");
499
500    let output = Command::new("git")
501        .args([
502            "commit-tree",
503            tree.as_str(),
504            "-p",
505            parent.as_str(),
506            "-m",
507            message,
508        ])
509        .current_dir(root)
510        .env("GIT_AUTHOR_DATE", &timestamp)
511        .env("GIT_COMMITTER_DATE", &timestamp)
512        .output()?;
513
514    if !output.status.success() {
515        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
516        return Err(BuildError::GitCommand {
517            command: format!("git commit-tree {} -p {}", tree.as_str(), parent.as_str()),
518            stderr,
519            exit_code: output.status.code(),
520        });
521    }
522
523    let raw = String::from_utf8_lossy(&output.stdout).trim().to_owned();
524    GitOid::new(&raw).map_err(|_| BuildError::InvalidOid {
525        context: "git commit-tree output".to_owned(),
526        raw,
527    })
528}
529
530// ---------------------------------------------------------------------------
531// Tests
532// ---------------------------------------------------------------------------
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use crate::model::types::{EpochId, WorkspaceId};
538    use std::fs;
539    use std::process::Command;
540    use tempfile::TempDir;
541
542    // -----------------------------------------------------------------------
543    // Test helpers
544    // -----------------------------------------------------------------------
545
546    /// Set up a fresh git repo with git identity configured.
547    fn setup_git_repo() -> (TempDir, EpochId) {
548        let dir = TempDir::new().unwrap();
549        let root = dir.path();
550
551        run_git(root, &["init"]);
552        run_git(root, &["config", "user.name", "Test"]);
553        run_git(root, &["config", "user.email", "test@test.com"]);
554        run_git(root, &["config", "commit.gpgsign", "false"]);
555
556        // Initial commit with README.md
557        fs::write(root.join("README.md"), "# Test\n").unwrap();
558        run_git(root, &["add", "README.md"]);
559        run_git(root, &["commit", "-m", "initial"]);
560
561        let oid = git_oid(root, "HEAD");
562        let epoch = EpochId::new(oid.as_str()).unwrap();
563        (dir, epoch)
564    }
565
566    fn run_git(root: &Path, args: &[&str]) {
567        let out = Command::new("git")
568            .args(args)
569            .current_dir(root)
570            .output()
571            .unwrap();
572        assert!(
573            out.status.success(),
574            "git {} failed: {}",
575            args.join(" "),
576            String::from_utf8_lossy(&out.stderr)
577        );
578    }
579
580    fn git_oid(root: &Path, rev: &str) -> GitOid {
581        let out = Command::new("git")
582            .args(["rev-parse", rev])
583            .current_dir(root)
584            .output()
585            .unwrap();
586        assert!(out.status.success(), "rev-parse {rev} failed");
587        GitOid::new(String::from_utf8_lossy(&out.stdout).trim()).unwrap()
588    }
589
590    fn git_file_content(root: &Path, commit: &str, path: &str) -> String {
591        let spec = format!("{commit}:{path}");
592        let out = Command::new("git")
593            .args(["show", &spec])
594            .current_dir(root)
595            .output()
596            .unwrap();
597        assert!(
598            out.status.success(),
599            "git show {spec} failed: {}",
600            String::from_utf8_lossy(&out.stderr)
601        );
602        String::from_utf8_lossy(&out.stdout).into_owned()
603    }
604
605    fn git_ls_tree_flat(root: &Path, commit: &str) -> Vec<String> {
606        let out = Command::new("git")
607            .args(["ls-tree", "-r", "--full-tree", "--name-only", commit])
608            .current_dir(root)
609            .output()
610            .unwrap();
611        assert!(out.status.success(), "git ls-tree failed");
612        String::from_utf8_lossy(&out.stdout)
613            .lines()
614            .map(str::to_owned)
615            .filter(|l| !l.is_empty())
616            .collect()
617    }
618
619    fn ws_ids(names: &[&str]) -> Vec<WorkspaceId> {
620        names.iter().map(|n| WorkspaceId::new(n).unwrap()).collect()
621    }
622
623    // -----------------------------------------------------------------------
624    // ResolvedChange tests
625    // -----------------------------------------------------------------------
626
627    #[test]
628    fn resolved_change_path_upsert() {
629        let rc = ResolvedChange::Upsert {
630            path: PathBuf::from("foo.rs"),
631            content: vec![],
632        };
633        assert_eq!(rc.path(), &PathBuf::from("foo.rs"));
634        assert!(rc.is_upsert());
635        assert!(!rc.is_delete());
636    }
637
638    #[test]
639    fn resolved_change_path_delete() {
640        let rc = ResolvedChange::Delete {
641            path: PathBuf::from("bar.rs"),
642        };
643        assert_eq!(rc.path(), &PathBuf::from("bar.rs"));
644        assert!(!rc.is_upsert());
645        assert!(rc.is_delete());
646    }
647
648    // -----------------------------------------------------------------------
649    // No changes → tree identical to epoch
650    // -----------------------------------------------------------------------
651
652    #[test]
653    fn build_with_no_changes_matches_epoch_tree() {
654        let (dir, epoch) = setup_git_repo();
655        let root = dir.path();
656
657        let commit_oid = build_merge_commit(root, &epoch, &ws_ids(&["alpha"]), &[], None).unwrap();
658
659        // The new commit should have the same tree as the epoch.
660        let epoch_tree = git_oid(root, &format!("{}^{{tree}}", epoch.as_str()));
661        let new_tree = git_oid(root, &format!("{}^{{tree}}", commit_oid.as_str()));
662        assert_eq!(
663            epoch_tree, new_tree,
664            "empty change-set should preserve tree"
665        );
666    }
667
668    // -----------------------------------------------------------------------
669    // Add a new file
670    // -----------------------------------------------------------------------
671
672    #[test]
673    fn build_adds_new_file() {
674        let (dir, epoch) = setup_git_repo();
675        let root = dir.path();
676
677        let resolved = vec![ResolvedChange::Upsert {
678            path: PathBuf::from("src/main.rs"),
679            content: b"fn main() {}".to_vec(),
680        }];
681
682        let commit_oid =
683            build_merge_commit(root, &epoch, &ws_ids(&["agent-1"]), &resolved, None).unwrap();
684
685        // File should be present in the new commit.
686        let content = git_file_content(root, commit_oid.as_str(), "src/main.rs");
687        assert_eq!(content, "fn main() {}");
688
689        // Original file should still be present.
690        let readme = git_file_content(root, commit_oid.as_str(), "README.md");
691        assert_eq!(readme, "# Test\n");
692
693        // Flat tree should include both files.
694        let files = git_ls_tree_flat(root, commit_oid.as_str());
695        assert!(files.contains(&"README.md".to_owned()));
696        assert!(files.contains(&"src/main.rs".to_owned()));
697    }
698
699    // -----------------------------------------------------------------------
700    // Modify an existing file
701    // -----------------------------------------------------------------------
702
703    #[test]
704    fn build_modifies_existing_file() {
705        let (dir, epoch) = setup_git_repo();
706        let root = dir.path();
707
708        let resolved = vec![ResolvedChange::Upsert {
709            path: PathBuf::from("README.md"),
710            content: b"# Updated\n".to_vec(),
711        }];
712
713        let commit_oid =
714            build_merge_commit(root, &epoch, &ws_ids(&["agent-1"]), &resolved, None).unwrap();
715
716        let content = git_file_content(root, commit_oid.as_str(), "README.md");
717        assert_eq!(content, "# Updated\n");
718    }
719
720    // -----------------------------------------------------------------------
721    // Delete a file
722    // -----------------------------------------------------------------------
723
724    #[test]
725    fn build_deletes_file() {
726        let (dir, epoch) = setup_git_repo();
727        let root = dir.path();
728
729        let resolved = vec![ResolvedChange::Delete {
730            path: PathBuf::from("README.md"),
731        }];
732
733        let commit_oid =
734            build_merge_commit(root, &epoch, &ws_ids(&["agent-1"]), &resolved, None).unwrap();
735
736        // File should be absent from the new tree.
737        let files = git_ls_tree_flat(root, commit_oid.as_str());
738        assert!(
739            !files.contains(&"README.md".to_owned()),
740            "README.md should be deleted: {files:?}"
741        );
742    }
743
744    // -----------------------------------------------------------------------
745    // Mixed: add, modify, delete in one merge
746    // -----------------------------------------------------------------------
747
748    #[test]
749    fn build_mixed_changes() {
750        // Set up epoch with two files: README.md and lib.rs
751        let (dir, epoch0) = setup_git_repo();
752        let root = dir.path();
753        fs::write(root.join("lib.rs"), "pub fn lib() {}\n").unwrap();
754        run_git(root, &["add", "lib.rs"]);
755        run_git(root, &["commit", "-m", "add lib.rs"]);
756        let epoch_oid = git_oid(root, "HEAD");
757        let epoch = EpochId::new(epoch_oid.as_str()).unwrap();
758        drop(epoch0); // epoch0 not used further
759
760        let resolved = vec![
761            ResolvedChange::Upsert {
762                path: PathBuf::from("README.md"),
763                content: b"# Modified\n".to_vec(),
764            },
765            ResolvedChange::Delete {
766                path: PathBuf::from("lib.rs"),
767            },
768            ResolvedChange::Upsert {
769                path: PathBuf::from("src/new.rs"),
770                content: b"pub fn new() {}\n".to_vec(),
771            },
772        ];
773
774        let commit_oid =
775            build_merge_commit(root, &epoch, &ws_ids(&["a", "b"]), &resolved, None).unwrap();
776
777        // README.md modified
778        let readme = git_file_content(root, commit_oid.as_str(), "README.md");
779        assert_eq!(readme, "# Modified\n");
780
781        // lib.rs deleted
782        let files = git_ls_tree_flat(root, commit_oid.as_str());
783        assert!(
784            !files.contains(&"lib.rs".to_owned()),
785            "lib.rs should be gone"
786        );
787
788        // src/new.rs added
789        assert!(
790            files.contains(&"src/new.rs".to_owned()),
791            "src/new.rs should be present: {files:?}"
792        );
793    }
794
795    // -----------------------------------------------------------------------
796    // Commit message: auto-generated from workspace IDs
797    // -----------------------------------------------------------------------
798
799    #[test]
800    fn build_commit_message_default() {
801        let (dir, epoch) = setup_git_repo();
802        let root = dir.path();
803
804        let commit_oid =
805            build_merge_commit(root, &epoch, &ws_ids(&["beta", "alpha"]), &[], None).unwrap();
806
807        let log_out = Command::new("git")
808            .args(["log", "--format=%s", "-1", commit_oid.as_str()])
809            .current_dir(root)
810            .output()
811            .unwrap();
812        let subject = String::from_utf8_lossy(&log_out.stdout).trim().to_owned();
813
814        // Workspace IDs should be sorted: alpha before beta.
815        assert_eq!(subject, "epoch: merge alpha beta");
816    }
817
818    #[test]
819    fn build_commit_message_custom() {
820        let (dir, epoch) = setup_git_repo();
821        let root = dir.path();
822
823        let commit_oid =
824            build_merge_commit(root, &epoch, &ws_ids(&["a"]), &[], Some("custom: my merge"))
825                .unwrap();
826
827        let log_out = Command::new("git")
828            .args(["log", "--format=%s", "-1", commit_oid.as_str()])
829            .current_dir(root)
830            .output()
831            .unwrap();
832        let subject = String::from_utf8_lossy(&log_out.stdout).trim().to_owned();
833        assert_eq!(subject, "custom: my merge");
834    }
835
836    // -----------------------------------------------------------------------
837    // Parent commit
838    // -----------------------------------------------------------------------
839
840    #[test]
841    fn build_commit_parent_is_epoch() {
842        let (dir, epoch) = setup_git_repo();
843        let root = dir.path();
844
845        let commit_oid = build_merge_commit(root, &epoch, &ws_ids(&["ws1"]), &[], None).unwrap();
846
847        // New commit's parent must be the epoch.
848        let parent_out = Command::new("git")
849            .args(["rev-parse", &format!("{}^", commit_oid.as_str())])
850            .current_dir(root)
851            .output()
852            .unwrap();
853        let parent_oid = GitOid::new(String::from_utf8_lossy(&parent_out.stdout).trim()).unwrap();
854        assert_eq!(parent_oid, *epoch.oid());
855    }
856
857    // -----------------------------------------------------------------------
858    // Determinism: same inputs → same tree OID
859    // -----------------------------------------------------------------------
860
861    #[test]
862    fn build_tree_is_deterministic() {
863        let (dir, epoch) = setup_git_repo();
864        let root = dir.path();
865
866        let resolved = vec![
867            ResolvedChange::Upsert {
868                path: PathBuf::from("a.rs"),
869                content: b"fn a() {}".to_vec(),
870            },
871            ResolvedChange::Upsert {
872                path: PathBuf::from("b.rs"),
873                content: b"fn b() {}".to_vec(),
874            },
875        ];
876
877        let oid1 =
878            build_merge_commit(root, &epoch, &ws_ids(&["ws-a", "ws-b"]), &resolved, None).unwrap();
879        let oid2 =
880            build_merge_commit(root, &epoch, &ws_ids(&["ws-a", "ws-b"]), &resolved, None).unwrap();
881
882        // Tree OIDs must be identical (content-addressed).
883        let tree1 = git_oid(root, &format!("{}^{{tree}}", oid1.as_str()));
884        let tree2 = git_oid(root, &format!("{}^{{tree}}", oid2.as_str()));
885        assert_eq!(tree1, tree2, "same inputs must produce same tree OID");
886    }
887
888    // -----------------------------------------------------------------------
889    // Merge commits use real timestamps (not fixed/synthetic)
890    // -----------------------------------------------------------------------
891
892    #[test]
893    fn build_commit_uses_real_timestamp() {
894        let (dir, epoch) = setup_git_repo();
895        let root = dir.path();
896
897        let before = std::time::SystemTime::now()
898            .duration_since(std::time::UNIX_EPOCH)
899            .unwrap()
900            .as_secs();
901
902        let commit_oid =
903            build_merge_commit(root, &epoch, &ws_ids(&["ws1"]), &[], None).unwrap();
904
905        let after = std::time::SystemTime::now()
906            .duration_since(std::time::UNIX_EPOCH)
907            .unwrap()
908            .as_secs();
909
910        // Read the author date from the commit.
911        let log_out = Command::new("git")
912            .args(["log", "--format=%at", "-1", commit_oid.as_str()])
913            .current_dir(root)
914            .output()
915            .unwrap();
916        let author_ts: u64 = String::from_utf8_lossy(&log_out.stdout)
917            .trim()
918            .parse()
919            .unwrap();
920
921        assert!(
922            author_ts >= before && author_ts <= after,
923            "commit timestamp {author_ts} should be between {before} and {after}"
924        );
925    }
926
927    // -----------------------------------------------------------------------
928    // Deeply nested paths
929    // -----------------------------------------------------------------------
930
931    #[test]
932    fn build_handles_nested_paths() {
933        let (dir, epoch) = setup_git_repo();
934        let root = dir.path();
935
936        let resolved = vec![
937            ResolvedChange::Upsert {
938                path: PathBuf::from("a/b/c/deep.rs"),
939                content: b"fn deep() {}".to_vec(),
940            },
941            ResolvedChange::Upsert {
942                path: PathBuf::from("a/b/c/other.rs"),
943                content: b"fn other() {}".to_vec(),
944            },
945        ];
946
947        let commit_oid =
948            build_merge_commit(root, &epoch, &ws_ids(&["ws"]), &resolved, None).unwrap();
949
950        let files = git_ls_tree_flat(root, commit_oid.as_str());
951        assert!(
952            files.contains(&"a/b/c/deep.rs".to_owned()),
953            "nested path should be present: {files:?}"
954        );
955        assert!(
956            files.contains(&"a/b/c/other.rs".to_owned()),
957            "nested path should be present: {files:?}"
958        );
959    }
960
961    // -----------------------------------------------------------------------
962    // Empty workspace list
963    // -----------------------------------------------------------------------
964
965    #[test]
966    fn build_empty_workspace_list_uses_generic_message() {
967        let (dir, epoch) = setup_git_repo();
968        let root = dir.path();
969
970        let commit_oid = build_merge_commit(root, &epoch, &[], &[], None).unwrap();
971
972        let log_out = Command::new("git")
973            .args(["log", "--format=%s", "-1", commit_oid.as_str()])
974            .current_dir(root)
975            .output()
976            .unwrap();
977        let subject = String::from_utf8_lossy(&log_out.stdout).trim().to_owned();
978        assert_eq!(subject, "epoch: merge");
979    }
980
981    // -----------------------------------------------------------------------
982    // Delete non-existent file is a no-op
983    // -----------------------------------------------------------------------
984
985    #[test]
986    fn build_delete_nonexistent_file_is_noop() {
987        let (dir, epoch) = setup_git_repo();
988        let root = dir.path();
989
990        let resolved = vec![ResolvedChange::Delete {
991            path: PathBuf::from("does-not-exist.rs"),
992        }];
993
994        // Should succeed (deleting absent path is harmless)
995        let commit_oid =
996            build_merge_commit(root, &epoch, &ws_ids(&["ws"]), &resolved, None).unwrap();
997
998        // README.md should still be present
999        let files = git_ls_tree_flat(root, commit_oid.as_str());
1000        assert!(
1001            files.contains(&"README.md".to_owned()),
1002            "README.md should still be present: {files:?}"
1003        );
1004    }
1005
1006    // -----------------------------------------------------------------------
1007    // BuildError display
1008    // -----------------------------------------------------------------------
1009
1010    #[test]
1011    fn build_error_display_git_command() {
1012        let err = BuildError::GitCommand {
1013            command: "git mktree".to_owned(),
1014            stderr: "fatal: bad input".to_owned(),
1015            exit_code: Some(128),
1016        };
1017        let msg = format!("{err}");
1018        assert!(msg.contains("git mktree"), "missing command: {msg}");
1019        assert!(msg.contains("128"), "missing exit code: {msg}");
1020        assert!(msg.contains("fatal: bad input"), "missing stderr: {msg}");
1021    }
1022
1023    #[test]
1024    fn build_error_display_malformed_ls_tree() {
1025        let err = BuildError::MalformedLsTree {
1026            line: "garbage line".to_owned(),
1027        };
1028        let msg = format!("{err}");
1029        assert!(msg.contains("garbage line"), "missing line: {msg}");
1030    }
1031
1032    #[test]
1033    fn build_error_display_invalid_oid() {
1034        let err = BuildError::InvalidOid {
1035            context: "test context".to_owned(),
1036            raw: "not-an-oid".to_owned(),
1037        };
1038        let msg = format!("{err}");
1039        assert!(msg.contains("test context"), "missing context: {msg}");
1040        assert!(msg.contains("not-an-oid"), "missing raw: {msg}");
1041    }
1042}