Skip to main content

dk_engine/workspace/
merge.rs

1//! Workspace merge with fast-path and rebase strategies.
2//!
3//! When the repository HEAD still equals the workspace's base commit, a
4//! fast-forward merge applies the overlay directly via
5//! `commit_tree_overlay`. When HEAD has advanced, a rebase path checks
6//! each modified file for semantic conflicts and auto-merges where
7//! possible.
8
9use std::collections::HashMap;
10
11use dk_core::Result;
12
13use crate::git::GitRepository;
14use crate::parser::ParserRegistry;
15use crate::workspace::conflict::{analyze_file_conflict, MergeAnalysis, SemanticConflict};
16use crate::workspace::session_workspace::SessionWorkspace;
17
18// ── Result types ─────────────────────────────────────────────────────
19
20/// Outcome of attempting to merge a workspace into the repository.
21#[derive(Debug)]
22pub enum WorkspaceMergeResult {
23    /// HEAD == base_commit: overlay was committed directly.
24    FastMerge { commit_hash: String },
25
26    /// HEAD != base_commit, but all files auto-rebased successfully.
27    RebaseMerge {
28        commit_hash: String,
29        /// File paths that were automatically rebased.
30        auto_rebased_files: Vec<String>,
31    },
32
33    /// At least one file has semantic conflicts that need resolution.
34    Conflicts {
35        conflicts: Vec<SemanticConflict>,
36    },
37}
38
39// ── Merge function ───────────────────────────────────────────────────
40
41/// Attempt to merge a workspace's overlay into the repository.
42///
43/// Strategy:
44/// 1. **Fast path** — If the repo HEAD equals the workspace's
45///    `base_commit`, commit the overlay directly on top.
46/// 2. **Rebase path** — If HEAD has advanced, compare each overlay file
47///    against the HEAD version using three-way semantic conflict analysis.
48///    Files with non-overlapping changes are auto-merged; files with
49///    overlapping symbol changes produce conflicts.
50pub fn merge_workspace(
51    workspace: &SessionWorkspace,
52    git_repo: &GitRepository,
53    parser: &ParserRegistry,
54    commit_message: &str,
55    author_name: &str,
56    author_email: &str,
57) -> Result<WorkspaceMergeResult> {
58    let overlay = workspace.overlay_for_tree();
59
60    if overlay.is_empty() {
61        return Err(dk_core::Error::Internal(
62            "workspace has no changes to merge".into(),
63        ));
64    }
65
66    // ── Initial commit (empty repo) ──────────────────────────────
67    //
68    // When the repository has no HEAD (no commits yet) and the workspace
69    // base_commit is "initial", create an orphan root commit from the
70    // overlay. This supports the first-ever commit on a new repository.
71    let head_hash = match git_repo.head_hash()? {
72        Some(hash) => hash,
73        None => {
74            if workspace.base_commit == "initial" {
75                let commit_hash = git_repo.commit_initial_overlay(
76                    &overlay,
77                    commit_message,
78                    author_name,
79                    author_email,
80                )?;
81                return Ok(WorkspaceMergeResult::FastMerge { commit_hash });
82            }
83            return Err(dk_core::Error::Git("repository has no HEAD".into()));
84        }
85    };
86
87    // ── Fast path ────────────────────────────────────────────────
88    if head_hash == workspace.base_commit {
89        let commit_hash = git_repo.commit_tree_overlay(
90            &workspace.base_commit,
91            &overlay,
92            &workspace.base_commit,
93            commit_message,
94            author_name,
95            author_email,
96        )?;
97
98        return Ok(WorkspaceMergeResult::FastMerge { commit_hash });
99    }
100
101    // ── Rebase path ──────────────────────────────────────────────
102    //
103    // Batch-read all tree entries upfront so that consecutive lookups
104    // against the same commit let gitoxide reuse the resolved tree
105    // object from its internal cache, instead of re-resolving
106    // commit→tree on every interleaved per-file call.
107    let paths: Vec<&String> = overlay.iter().map(|(p, _)| p).collect();
108
109    let mut base_entries: HashMap<&str, Option<Vec<u8>>> = HashMap::with_capacity(paths.len());
110    for path in &paths {
111        base_entries.insert(path.as_str(), git_repo.read_tree_entry(&workspace.base_commit, path).ok());
112    }
113
114    let mut head_entries: HashMap<&str, Option<Vec<u8>>> = HashMap::with_capacity(paths.len());
115    for path in &paths {
116        head_entries.insert(path.as_str(), git_repo.read_tree_entry(&head_hash, path).ok());
117    }
118
119    let mut all_conflicts = Vec::new();
120    let mut auto_rebased = Vec::new();
121    let mut rebased_overlay: Vec<(String, Option<Vec<u8>>)> = Vec::new();
122
123    for (path, maybe_content) in &overlay {
124        let base_content = base_entries.get(path.as_str()).and_then(|v| v.as_ref());
125        let head_content = head_entries.get(path.as_str()).and_then(|v| v.as_ref());
126
127        match maybe_content {
128            None => {
129                // Deletion — check if the file was also modified in head.
130                match (base_content, head_content) {
131                    (Some(base), Some(head)) => {
132                        if base == head {
133                            rebased_overlay.push((path.clone(), None));
134                        } else {
135                            all_conflicts.push(SemanticConflict {
136                                file_path: path.clone(),
137                                symbol_name: "<entire file>".to_string(),
138                                our_change: crate::workspace::conflict::SymbolChangeKind::Removed,
139                                their_change: crate::workspace::conflict::SymbolChangeKind::Modified,
140                            });
141                        }
142                    }
143                    _ => {
144                        rebased_overlay.push((path.clone(), None));
145                    }
146                }
147            }
148            Some(overlay_content) => {
149                match (base_content, head_content) {
150                    (Some(base), Some(head)) => {
151                        if base == head {
152                            rebased_overlay.push((path.clone(), Some(overlay_content.clone())));
153                        } else {
154                            let analysis = analyze_file_conflict(
155                                path,
156                                base,
157                                head,
158                                overlay_content,
159                                parser,
160                            );
161
162                            match analysis {
163                                MergeAnalysis::AutoMerge { merged_content } => {
164                                    rebased_overlay
165                                        .push((path.clone(), Some(merged_content)));
166                                    auto_rebased.push(path.clone());
167                                }
168                                MergeAnalysis::Conflict { conflicts } => {
169                                    all_conflicts.extend(conflicts);
170                                }
171                            }
172                        }
173                    }
174                    (None, Some(head_blob)) => {
175                        if *head_blob == *overlay_content {
176                            rebased_overlay.push((path.clone(), Some(overlay_content.clone())));
177                        } else {
178                            all_conflicts.push(SemanticConflict {
179                                file_path: path.clone(),
180                                symbol_name: "<entire file>".to_string(),
181                                our_change: crate::workspace::conflict::SymbolChangeKind::Added,
182                                their_change: crate::workspace::conflict::SymbolChangeKind::Added,
183                            });
184                        }
185                    }
186                    (None, None) => {
187                        rebased_overlay.push((path.clone(), Some(overlay_content.clone())));
188                    }
189                    (Some(_), None) => {
190                        all_conflicts.push(SemanticConflict {
191                            file_path: path.clone(),
192                            symbol_name: "<entire file>".to_string(),
193                            our_change: crate::workspace::conflict::SymbolChangeKind::Modified,
194                            their_change: crate::workspace::conflict::SymbolChangeKind::Removed,
195                        });
196                    }
197                }
198            }
199        }
200    }
201
202    if !all_conflicts.is_empty() {
203        return Ok(WorkspaceMergeResult::Conflicts {
204            conflicts: all_conflicts,
205        });
206    }
207
208    // All files rebased successfully — commit on top of HEAD.
209    let commit_hash = git_repo.commit_tree_overlay(
210        &head_hash,
211        &rebased_overlay,
212        &head_hash,
213        commit_message,
214        author_name,
215        author_email,
216    )?;
217
218    Ok(WorkspaceMergeResult::RebaseMerge {
219        commit_hash,
220        auto_rebased_files: auto_rebased,
221    })
222}