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
9const CONFLICT_TYPE_TRUE: &str = "true_conflict";
11
12fn 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 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 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 let ws = engine
56 .workspace_manager()
57 .get_workspace(&sid)
58 .ok_or_else(|| Status::not_found("Workspace not found for session"))?;
59
60 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 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 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(ws);
95
96 match merge_result {
97 WorkspaceMergeResult::FastMerge { commit_hash } => {
98 engine.changeset_store().set_merged(changeset_id, &commit_hash).await
100 .map_err(|e| Status::internal(e.to_string()))?;
101
102 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 engine.changeset_store().set_merged(changeset_id, &commit_hash).await
132 .map_err(|e| Status::internal(e.to_string()))?;
133
134 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 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
205pub 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 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 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 assert_eq!(sanitize_for_proto("fn résumé()"), "fn résumé()");
342 assert_eq!(sanitize_for_proto("日本語"), "日本語");
343 assert_eq!(sanitize_for_proto("bad\u{FFFD}char"), "bad\u{FFFD}char");
345 }
346}