Skip to main content

dk_protocol/
pre_submit.rs

1use std::collections::HashMap;
2
3use tonic::{Response, Status};
4use tracing::info;
5
6use crate::server::ProtocolServer;
7use crate::{PreSubmitCheckRequest, PreSubmitCheckResponse, SemanticConflict};
8
9/// Handle a PreSubmitCheck RPC.
10///
11/// Performs a dry-run conflict detection:
12/// 1. Retrieves the session workspace.
13/// 2. Compares the workspace overlay against current HEAD.
14/// 3. Uses the semantic conflict detector to find overlapping changes.
15/// 4. Reports conflicts, file count, and symbol change count.
16pub async fn handle_pre_submit_check(
17    server: &ProtocolServer,
18    req: PreSubmitCheckRequest,
19) -> Result<Response<PreSubmitCheckResponse>, Status> {
20    let session = server.validate_session(&req.session_id)?;
21    crate::require_live_session::require_live_session(server, &req.session_id).await?;
22
23    let sid = req
24        .session_id
25        .parse::<uuid::Uuid>()
26        .map_err(|_| Status::invalid_argument("Invalid session ID"))?;
27    server.session_mgr().touch_session(&sid);
28
29    let engine = server.engine();
30
31    // Get workspace
32    let ws = engine
33        .workspace_manager()
34        .get_workspace(&sid)
35        .ok_or_else(|| Status::not_found("Workspace not found for session"))?;
36
37    // Get git repo to read HEAD
38    let (_repo_id, git_repo) = engine
39        .get_repo(&session.codebase)
40        .await
41        .map_err(|e| Status::internal(format!("Repo error: {e}")))?;
42
43    let head_hash = git_repo
44        .head_hash()
45        .map_err(|e| Status::internal(format!("Failed to read HEAD: {e}")))?
46        .unwrap_or_else(|| "initial".to_string());
47
48    let overlay = ws.overlay_for_tree();
49    let files_modified = overlay.len() as u32;
50    let symbols_changed = ws.graph.change_count() as u32;
51
52    // If HEAD == base_commit, no conflicts are possible (fast-forward path)
53    if head_hash == ws.base_commit || overlay.is_empty() {
54        info!(
55            session_id = %req.session_id,
56            files_modified,
57            symbols_changed,
58            "PRE_SUBMIT_CHECK: clean (fast-forward possible)"
59        );
60
61        return Ok(Response::new(PreSubmitCheckResponse {
62            has_conflicts: false,
63            potential_conflicts: Vec::new(),
64            files_modified,
65            symbols_changed,
66        }));
67    }
68
69    // HEAD has advanced since workspace was created — check for conflicts.
70    //
71    // Batch-read all tree entries upfront so consecutive lookups against
72    // the same commit let gitoxide reuse the resolved tree object from
73    // its internal cache.
74    let parser = engine.parser();
75    let paths: Vec<&String> = overlay.iter().map(|(p, _)| p).collect();
76
77    let mut base_entries: HashMap<&str, Option<Vec<u8>>> = HashMap::with_capacity(paths.len());
78    for path in &paths {
79        base_entries.insert(path.as_str(), git_repo.read_tree_entry(&ws.base_commit, path).ok());
80    }
81
82    let mut head_entries: HashMap<&str, Option<Vec<u8>>> = HashMap::with_capacity(paths.len());
83    for path in &paths {
84        head_entries.insert(path.as_str(), git_repo.read_tree_entry(&head_hash, path).ok());
85    }
86
87    let mut conflicts = Vec::new();
88
89    for (path, maybe_content) in &overlay {
90        let base_content = base_entries.get(path.as_str()).and_then(|v| v.as_ref());
91        let head_content = head_entries.get(path.as_str()).and_then(|v| v.as_ref());
92
93        match maybe_content {
94            None => {
95                // Deletion — check if HEAD also changed this file
96                if let (Some(base), Some(head)) = (base_content, head_content) {
97                    if base != head {
98                        conflicts.push(SemanticConflict {
99                            file_path: path.clone(),
100                            symbol_name: "<entire file>".to_string(),
101                            our_change: "deleted".to_string(),
102                            their_change: "modified".to_string(),
103                        });
104                    }
105                }
106            }
107            Some(overlay_content) => {
108                match (base_content, head_content) {
109                    (Some(base), Some(head)) => {
110                        if base != head {
111                            let analysis =
112                                dk_engine::workspace::conflict::analyze_file_conflict(
113                                    path,
114                                    base,
115                                    head,
116                                    overlay_content,
117                                    parser,
118                                );
119
120                            if let dk_engine::workspace::conflict::MergeAnalysis::Conflict {
121                                conflicts: file_conflicts,
122                            } = analysis
123                            {
124                                for c in file_conflicts {
125                                    conflicts.push(SemanticConflict {
126                                        file_path: c.file_path,
127                                        symbol_name: c.symbol_name,
128                                        our_change: format!("{:?}", c.our_change),
129                                        their_change: format!("{:?}", c.their_change),
130                                    });
131                                }
132                            }
133                        }
134                    }
135                    (None, Some(head_blob)) => {
136                        if *overlay_content != *head_blob {
137                            conflicts.push(SemanticConflict {
138                                file_path: path.clone(),
139                                symbol_name: "<entire file>".to_string(),
140                                our_change: "added".to_string(),
141                                their_change: "added".to_string(),
142                            });
143                        }
144                    }
145                    (Some(_), None) => {
146                        conflicts.push(SemanticConflict {
147                            file_path: path.clone(),
148                            symbol_name: "<entire file>".to_string(),
149                            our_change: "modified".to_string(),
150                            their_change: "deleted".to_string(),
151                        });
152                    }
153                    (None, None) => {
154                        // Pure addition, no conflict
155                    }
156                }
157            }
158        }
159    }
160
161    let has_conflicts = !conflicts.is_empty();
162
163    info!(
164        session_id = %req.session_id,
165        has_conflicts,
166        conflict_count = conflicts.len(),
167        files_modified,
168        symbols_changed,
169        "PRE_SUBMIT_CHECK: completed"
170    );
171
172    Ok(Response::new(PreSubmitCheckResponse {
173        has_conflicts,
174        potential_conflicts: conflicts,
175        files_modified,
176        symbols_changed,
177    }))
178}