Skip to main content

dk_protocol/
merge.rs

1use tonic::Status;
2use uuid::Uuid;
3
4use dk_engine::workspace::merge::{merge_workspace, WorkspaceMergeResult};
5
6use crate::server::ProtocolServer;
7use crate::{merge_response, ConflictDetail, MergeConflict, MergeRequest, MergeResponse, MergeSuccess};
8
9/// Conflict type for true write-write semantic conflicts.
10const CONFLICT_TYPE_TRUE: &str = "true_conflict";
11
12/// Sanitize a string for protobuf `string` fields.
13///
14/// Rust `String` is guaranteed valid UTF-8, but content originating from
15/// tree-sitter AST parsing may contain null bytes or replacement characters
16/// from lossy conversions.  Strip null bytes so the value round-trips cleanly
17/// through protobuf serialization/deserialization.
18fn sanitize_for_proto(s: &str) -> String {
19    s.replace('\0', "")
20}
21
22pub async fn handle_merge(
23    server: &ProtocolServer,
24    req: MergeRequest,
25) -> Result<MergeResponse, Status> {
26    let session = server.validate_session(&req.session_id)?;
27    let engine = server.engine();
28
29    let sid = req
30        .session_id
31        .parse::<Uuid>()
32        .map_err(|_| Status::invalid_argument("Invalid session ID"))?;
33
34    // Resolve repo_id for enriched events
35    let repo_id_str = match engine.get_repo(&session.codebase).await {
36        Ok((rid, _)) => rid.to_string(),
37        Err(_) => String::new(),
38    };
39
40    let changeset_id = req.changeset_id.parse::<Uuid>()
41        .map_err(|_| Status::invalid_argument("invalid changeset_id"))?;
42
43    // Get changeset and verify it's approved
44    let changeset = engine.changeset_store().get(changeset_id).await
45        .map_err(|e| Status::not_found(e.to_string()))?;
46
47    if changeset.state != "approved" {
48        return Err(Status::failed_precondition(format!(
49            "changeset is '{}', must be 'approved' to merge",
50            changeset.state
51        )));
52    }
53
54    // Get workspace for this session
55    let ws = engine
56        .workspace_manager()
57        .get_workspace(&sid)
58        .ok_or_else(|| Status::not_found("Workspace not found for session"))?;
59
60    // Get git repo
61    let (_, git_repo) = engine.get_repo(&session.codebase).await
62        .map_err(|e| Status::internal(e.to_string()))?;
63
64    let agent = changeset.agent_id.as_deref().unwrap_or("agent");
65
66    // Capture affected files from workspace overlay before merge/drop
67    let affected_files: Vec<crate::FileChange> = ws.overlay.list_changes()
68        .iter()
69        .map(|(path, entry)| {
70            let operation = match entry {
71                dk_engine::workspace::overlay::OverlayEntry::Added { .. } => "add",
72                dk_engine::workspace::overlay::OverlayEntry::Modified { .. } => "modify",
73                dk_engine::workspace::overlay::OverlayEntry::Deleted => "delete",
74            };
75            crate::FileChange {
76                path: path.clone(),
77                operation: operation.to_string(),
78            }
79        })
80        .collect();
81
82    // Use the programmatic workspace merge instead of git add -A
83    let merge_result = merge_workspace(
84        &ws,
85        &git_repo,
86        engine.parser(),
87        &req.commit_message,
88        agent,
89        &format!("{}@dkod.dev", agent),
90    )
91    .map_err(|e| Status::internal(format!("merge failed: {e}")))?;
92
93    // Drop workspace guard before further async work
94    drop(ws);
95
96    match merge_result {
97        WorkspaceMergeResult::FastMerge { commit_hash } => {
98            // Update changeset status to merged
99            engine.changeset_store().set_merged(changeset_id, &commit_hash).await
100                .map_err(|e| Status::internal(e.to_string()))?;
101
102            // Publish event
103            server.event_bus().publish(crate::WatchEvent {
104                event_type: "changeset.merged".to_string(),
105                changeset_id: changeset_id.to_string(),
106                agent_id: changeset.agent_id.clone().unwrap_or_default(),
107                affected_symbols: vec![],
108                details: format!("fast-merged as {}", commit_hash),
109                session_id: req.session_id.clone(),
110                affected_files: affected_files.clone(),
111                symbol_changes: vec![],
112                repo_id: repo_id_str.clone(),
113                event_id: Uuid::new_v4().to_string(),
114            });
115
116            Ok(MergeResponse {
117                result: Some(merge_response::Result::Success(MergeSuccess {
118                    commit_hash: commit_hash.clone(),
119                    merged_version: commit_hash,
120                    auto_rebased: false,
121                    auto_rebased_files: Vec::new(),
122                })),
123            })
124        }
125
126        WorkspaceMergeResult::RebaseMerge {
127            commit_hash,
128            auto_rebased_files,
129        } => {
130            // Update changeset status to merged
131            engine.changeset_store().set_merged(changeset_id, &commit_hash).await
132                .map_err(|e| Status::internal(e.to_string()))?;
133
134            // Publish event
135            server.event_bus().publish(crate::WatchEvent {
136                event_type: "changeset.merged".to_string(),
137                changeset_id: changeset_id.to_string(),
138                agent_id: changeset.agent_id.clone().unwrap_or_default(),
139                affected_symbols: vec![],
140                details: format!(
141                    "rebase-merged as {} (auto-rebased {} files)",
142                    commit_hash,
143                    auto_rebased_files.len()
144                ),
145                session_id: req.session_id.clone(),
146                affected_files,
147                symbol_changes: vec![],
148                repo_id: repo_id_str.clone(),
149                event_id: Uuid::new_v4().to_string(),
150            });
151
152            Ok(MergeResponse {
153                result: Some(merge_response::Result::Success(MergeSuccess {
154                    commit_hash: commit_hash.clone(),
155                    merged_version: commit_hash,
156                    auto_rebased: true,
157                    auto_rebased_files,
158                })),
159            })
160        }
161
162        WorkspaceMergeResult::Conflicts { conflicts } => {
163            let conflict_details: Vec<ConflictDetail> = conflicts
164                .iter()
165                .map(|c| {
166                    let file = sanitize_for_proto(&c.file_path);
167                    let symbol = sanitize_for_proto(&c.symbol_name);
168                    ConflictDetail {
169                        file_path: file,
170                        symbols: vec![symbol.clone()],
171                        your_agent: agent.to_string(),
172                        // TODO: resolve their_agent from the session/changeset store
173                        // once SemanticConflict carries agent attribution.
174                        their_agent: String::new(),
175                        conflict_type: CONFLICT_TYPE_TRUE.to_string(),
176                        description: format!(
177                            "Symbol '{}' — our change: {:?}, their change: {:?}",
178                            symbol, c.our_change, c.their_change
179                        ),
180                    }
181                })
182                .collect();
183
184            let suggested_action = "adapt".to_string();
185            let available_actions = vec!["adapt".to_string(), "keep_mine".to_string(), "keep_theirs".to_string()];
186
187            debug_assert!(
188                available_actions.iter().any(|a| a == &suggested_action),
189                "suggested_action '{}' is not in available_actions {:?}",
190                suggested_action, available_actions
191            );
192
193            Ok(MergeResponse {
194                result: Some(merge_response::Result::Conflict(MergeConflict {
195                    changeset_id: changeset_id.to_string(),
196                    conflicts: conflict_details,
197                    suggested_action,
198                    available_actions,
199                })),
200            })
201        }
202    }
203}
204
205// ── Event type constant ─────────────────────────────────────────────
206
207/// Event published when a changeset is successfully merged.
208pub const EVENT_MERGED: &str = "changeset.merged";
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn merged_event_type() {
216        assert_eq!(EVENT_MERGED, "changeset.merged");
217    }
218
219    #[test]
220    fn merged_event_type_uses_dot_separator() {
221        assert!(
222            EVENT_MERGED.contains('.'),
223            "event type should use dot separator"
224        );
225        assert!(
226            EVENT_MERGED.starts_with("changeset."),
227            "event type should start with 'changeset.'"
228        );
229    }
230
231    #[test]
232    fn merged_event_type_is_not_underscore_format() {
233        // Verify the event was renamed from "changeset_merged" to "changeset.merged"
234        assert_ne!(EVENT_MERGED, "changeset_merged");
235        assert_eq!(EVENT_MERGED, "changeset.merged");
236    }
237
238    #[test]
239    fn merge_success_construction() {
240        let resp = MergeResponse {
241            result: Some(merge_response::Result::Success(MergeSuccess {
242                commit_hash: "abc123".to_string(),
243                merged_version: "abc123".to_string(),
244                auto_rebased: false,
245                auto_rebased_files: Vec::new(),
246            })),
247        };
248        match resp.result {
249            Some(merge_response::Result::Success(s)) => {
250                assert_eq!(s.commit_hash, "abc123");
251                assert!(!s.auto_rebased);
252                assert!(s.auto_rebased_files.is_empty());
253            }
254            _ => panic!("expected MergeSuccess"),
255        }
256    }
257
258    #[test]
259    fn merge_success_with_rebase() {
260        let resp = MergeResponse {
261            result: Some(merge_response::Result::Success(MergeSuccess {
262                commit_hash: "def456".to_string(),
263                merged_version: "def456".to_string(),
264                auto_rebased: true,
265                auto_rebased_files: vec!["src/main.rs".to_string()],
266            })),
267        };
268        match resp.result {
269            Some(merge_response::Result::Success(s)) => {
270                assert!(s.auto_rebased);
271                assert_eq!(s.auto_rebased_files, vec!["src/main.rs"]);
272            }
273            _ => panic!("expected MergeSuccess"),
274        }
275    }
276
277    #[test]
278    fn merge_conflict_construction() {
279        // their_agent is currently not populated by the server (SemanticConflict
280        // does not carry agent attribution yet), so the test mirrors real
281        // behavior by using an empty string.
282        let detail = ConflictDetail {
283            file_path: "src/lib.rs".to_string(),
284            symbols: vec!["process_data".to_string()],
285            your_agent: "agent-1".to_string(),
286            their_agent: String::new(),
287            conflict_type: CONFLICT_TYPE_TRUE.to_string(),
288            description: "both agents modified process_data".to_string(),
289        };
290        let resp = MergeResponse {
291            result: Some(merge_response::Result::Conflict(MergeConflict {
292                changeset_id: "cs-001".to_string(),
293                conflicts: vec![detail],
294                suggested_action: "adapt".to_string(),
295                available_actions: vec![
296                    "adapt".to_string(),
297                    "keep_mine".to_string(),
298                    "keep_theirs".to_string(),
299                ],
300            })),
301        };
302        match resp.result {
303            Some(merge_response::Result::Conflict(c)) => {
304                assert_eq!(c.changeset_id, "cs-001");
305                assert_eq!(c.conflicts.len(), 1);
306                assert_eq!(c.conflicts[0].file_path, "src/lib.rs");
307                assert_eq!(c.conflicts[0].symbols, vec!["process_data"]);
308                assert_eq!(c.conflicts[0].your_agent, "agent-1");
309                assert!(c.conflicts[0].their_agent.is_empty());
310                assert_eq!(c.suggested_action, "adapt");
311                assert_eq!(c.available_actions.len(), 3);
312            }
313            _ => panic!("expected MergeConflict"),
314        }
315    }
316
317    #[test]
318    fn conflict_detail_fields() {
319        let detail = ConflictDetail {
320            file_path: "src/handler.rs".to_string(),
321            symbols: vec!["handle_request".to_string(), "parse_input".to_string()],
322            your_agent: "agent-a".to_string(),
323            their_agent: "agent-b".to_string(),
324            conflict_type: CONFLICT_TYPE_TRUE.to_string(),
325            description: "multiple symbols in conflict".to_string(),
326        };
327        assert_eq!(detail.symbols.len(), 2);
328        assert_eq!(detail.conflict_type, CONFLICT_TYPE_TRUE);
329    }
330
331    #[test]
332    fn sanitize_for_proto_strips_null_bytes() {
333        assert_eq!(sanitize_for_proto("hello\0world"), "helloworld");
334        assert_eq!(sanitize_for_proto("\0\0"), "");
335        assert_eq!(sanitize_for_proto("clean"), "clean");
336    }
337
338    #[test]
339    fn sanitize_for_proto_preserves_valid_utf8() {
340        // Multi-byte UTF-8 characters must survive sanitization
341        assert_eq!(sanitize_for_proto("fn résumé()"), "fn résumé()");
342        assert_eq!(sanitize_for_proto("日本語"), "日本語");
343        // Replacement character from String::from_utf8_lossy is valid UTF-8
344        assert_eq!(sanitize_for_proto("bad\u{FFFD}char"), "bad\u{FFFD}char");
345    }
346}