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
12use dk_core::sanitize_for_proto;
13
14pub async fn handle_merge(
15 server: &ProtocolServer,
16 req: MergeRequest,
17) -> Result<MergeResponse, Status> {
18 let session = server.validate_session(&req.session_id)?;
19 let engine = server.engine();
20
21 let sid = req
22 .session_id
23 .parse::<Uuid>()
24 .map_err(|_| Status::invalid_argument("Invalid session ID"))?;
25
26 let repo_id_str = match engine.get_repo(&session.codebase).await {
28 Ok((rid, _)) => rid.to_string(),
29 Err(_) => String::new(),
30 };
31
32 let changeset_id = req.changeset_id.parse::<Uuid>()
33 .map_err(|_| Status::invalid_argument("invalid changeset_id"))?;
34
35 let changeset = engine.changeset_store().get(changeset_id).await
37 .map_err(|e| Status::not_found(e.to_string()))?;
38
39 if changeset.state != "approved" {
40 return Err(Status::failed_precondition(format!(
41 "changeset is '{}', must be 'approved' to merge",
42 changeset.state
43 )));
44 }
45
46 let ws = engine
48 .workspace_manager()
49 .get_workspace(&sid)
50 .ok_or_else(|| Status::not_found("Workspace not found for session"))?;
51
52 let (repo_id, git_repo) = engine.get_repo(&session.codebase).await
55 .map_err(|e| Status::internal(e.to_string()))?;
56
57 let agent = changeset.agent_id.as_deref().unwrap_or("agent");
58
59 let (effective_name, effective_email) =
60 dk_core::resolve_author(&req.author_name, &req.author_email, agent);
61
62 let affected_files: Vec<crate::FileChange> = ws.overlay.list_changes()
64 .iter()
65 .map(|(path, entry)| {
66 let operation = match entry {
67 dk_engine::workspace::overlay::OverlayEntry::Added { .. } => "add",
68 dk_engine::workspace::overlay::OverlayEntry::Modified { .. } => "modify",
69 dk_engine::workspace::overlay::OverlayEntry::Deleted => "delete",
70 };
71 crate::FileChange {
72 path: path.clone(),
73 operation: operation.to_string(),
74 }
75 })
76 .collect();
77
78 let merge_result = merge_workspace(
80 &ws,
81 &git_repo,
82 engine.parser(),
83 &req.commit_message,
84 &effective_name,
85 &effective_email,
86 )
87 .map_err(|e| Status::internal(format!("merge failed: {e}")))?;
88
89 drop(ws);
91
92 match merge_result {
93 WorkspaceMergeResult::FastMerge { commit_hash } => {
94 release_locks_and_emit(server, repo_id, sid, &req.session_id, &changeset_id.to_string()).await;
99
100 engine.changeset_store().set_merged(changeset_id, &commit_hash).await
102 .map_err(|e| Status::internal(e.to_string()))?;
103
104 server.event_bus().publish(crate::WatchEvent {
106 event_type: "changeset.merged".to_string(),
107 changeset_id: changeset_id.to_string(),
108 agent_id: changeset.agent_id.clone().unwrap_or_default(),
109 affected_symbols: vec![],
110 details: format!("fast-merged as {}", commit_hash),
111 session_id: req.session_id.clone(),
112 affected_files: affected_files.clone(),
113 symbol_changes: vec![],
114 repo_id: repo_id_str.clone(),
115 event_id: Uuid::new_v4().to_string(),
116 });
117
118 Ok(MergeResponse {
119 result: Some(merge_response::Result::Success(MergeSuccess {
120 commit_hash: commit_hash.clone(),
121 merged_version: commit_hash,
122 auto_rebased: false,
123 auto_rebased_files: Vec::new(),
124 })),
125 })
126 }
127
128 WorkspaceMergeResult::RebaseMerge {
129 commit_hash,
130 auto_rebased_files,
131 } => {
132 release_locks_and_emit(server, repo_id, sid, &req.session_id, &changeset_id.to_string()).await;
135
136 engine.changeset_store().set_merged(changeset_id, &commit_hash).await
138 .map_err(|e| Status::internal(e.to_string()))?;
139
140 server.event_bus().publish(crate::WatchEvent {
142 event_type: "changeset.merged".to_string(),
143 changeset_id: changeset_id.to_string(),
144 agent_id: changeset.agent_id.clone().unwrap_or_default(),
145 affected_symbols: vec![],
146 details: format!(
147 "rebase-merged as {} (auto-rebased {} files)",
148 commit_hash,
149 auto_rebased_files.len()
150 ),
151 session_id: req.session_id.clone(),
152 affected_files,
153 symbol_changes: vec![],
154 repo_id: repo_id_str.clone(),
155 event_id: Uuid::new_v4().to_string(),
156 });
157
158 Ok(MergeResponse {
159 result: Some(merge_response::Result::Success(MergeSuccess {
160 commit_hash: commit_hash.clone(),
161 merged_version: commit_hash,
162 auto_rebased: true,
163 auto_rebased_files,
164 })),
165 })
166 }
167
168 WorkspaceMergeResult::Conflicts { conflicts } => {
169 let conflict_details: Vec<ConflictDetail> = conflicts
173 .iter()
174 .map(|c| {
175 let file = sanitize_for_proto(&c.file_path);
176 let symbol = sanitize_for_proto(&c.symbol_name);
177 ConflictDetail {
178 file_path: file,
179 symbols: vec![symbol.clone()],
180 your_agent: agent.to_string(),
181 their_agent: String::new(),
184 conflict_type: CONFLICT_TYPE_TRUE.to_string(),
185 description: format!(
186 "Symbol '{}' — our change: {:?}, their change: {:?}",
187 symbol, c.our_change, c.their_change
188 ),
189 }
190 })
191 .collect();
192
193 let suggested_action = "adapt".to_string();
194 let available_actions = vec!["adapt".to_string(), "keep_mine".to_string(), "keep_theirs".to_string()];
195
196 debug_assert!(
197 available_actions.iter().any(|a| a == &suggested_action),
198 "suggested_action '{}' is not in available_actions {:?}",
199 suggested_action, available_actions
200 );
201
202 Ok(MergeResponse {
203 result: Some(merge_response::Result::Conflict(MergeConflict {
204 changeset_id: changeset_id.to_string(),
205 conflicts: conflict_details,
206 suggested_action,
207 available_actions,
208 })),
209 })
210 }
211 }
212}
213
214pub(crate) async fn release_locks_and_emit(
224 server: &ProtocolServer,
225 repo_id: Uuid,
226 session_id: Uuid,
227 session_id_str: &str,
228 changeset_id: &str,
229) -> usize {
230 let released = server.claim_tracker().release_locks(repo_id, session_id).await;
231
232 if released.is_empty() {
233 return 0;
234 }
235
236 let count = released.len();
237
238 let mut by_file: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
240 for lock in &released {
241 by_file
242 .entry(lock.file_path.clone())
243 .or_default()
244 .push(lock.qualified_name.clone());
245 }
246
247 for (file_path, symbols) in by_file {
248 server.event_bus().publish(crate::WatchEvent {
249 event_type: EVENT_LOCK_RELEASED.to_string(),
250 changeset_id: changeset_id.to_string(),
251 agent_id: released.first().map(|r| r.agent_name.clone()).unwrap_or_default(),
252 affected_symbols: symbols,
253 details: format!("Symbol locks released on {}", file_path),
254 session_id: session_id_str.to_string(),
255 affected_files: vec![crate::FileChange {
256 path: file_path,
257 operation: "unlock".to_string(),
258 }],
259 symbol_changes: vec![],
260 repo_id: repo_id.to_string(),
261 event_id: Uuid::new_v4().to_string(),
262 });
263 }
264
265 count
266}
267
268pub const EVENT_MERGED: &str = "changeset.merged";
272
273pub const EVENT_LOCK_RELEASED: &str = "symbol.lock.released";
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn merged_event_type() {
283 assert_eq!(EVENT_MERGED, "changeset.merged");
284 }
285
286 #[test]
287 fn merged_event_type_uses_dot_separator() {
288 assert!(
289 EVENT_MERGED.contains('.'),
290 "event type should use dot separator"
291 );
292 assert!(
293 EVENT_MERGED.starts_with("changeset."),
294 "event type should start with 'changeset.'"
295 );
296 }
297
298 #[test]
299 fn merged_event_type_is_not_underscore_format() {
300 assert_ne!(EVENT_MERGED, "changeset_merged");
302 assert_eq!(EVENT_MERGED, "changeset.merged");
303 }
304
305 #[test]
306 fn merge_success_construction() {
307 let resp = MergeResponse {
308 result: Some(merge_response::Result::Success(MergeSuccess {
309 commit_hash: "abc123".to_string(),
310 merged_version: "abc123".to_string(),
311 auto_rebased: false,
312 auto_rebased_files: Vec::new(),
313 })),
314 };
315 match resp.result {
316 Some(merge_response::Result::Success(s)) => {
317 assert_eq!(s.commit_hash, "abc123");
318 assert!(!s.auto_rebased);
319 assert!(s.auto_rebased_files.is_empty());
320 }
321 _ => panic!("expected MergeSuccess"),
322 }
323 }
324
325 #[test]
326 fn merge_success_with_rebase() {
327 let resp = MergeResponse {
328 result: Some(merge_response::Result::Success(MergeSuccess {
329 commit_hash: "def456".to_string(),
330 merged_version: "def456".to_string(),
331 auto_rebased: true,
332 auto_rebased_files: vec!["src/main.rs".to_string()],
333 })),
334 };
335 match resp.result {
336 Some(merge_response::Result::Success(s)) => {
337 assert!(s.auto_rebased);
338 assert_eq!(s.auto_rebased_files, vec!["src/main.rs"]);
339 }
340 _ => panic!("expected MergeSuccess"),
341 }
342 }
343
344 #[test]
345 fn merge_conflict_construction() {
346 let detail = ConflictDetail {
350 file_path: "src/lib.rs".to_string(),
351 symbols: vec!["process_data".to_string()],
352 your_agent: "agent-1".to_string(),
353 their_agent: String::new(),
354 conflict_type: CONFLICT_TYPE_TRUE.to_string(),
355 description: "both agents modified process_data".to_string(),
356 };
357 let resp = MergeResponse {
358 result: Some(merge_response::Result::Conflict(MergeConflict {
359 changeset_id: "cs-001".to_string(),
360 conflicts: vec![detail],
361 suggested_action: "adapt".to_string(),
362 available_actions: vec![
363 "adapt".to_string(),
364 "keep_mine".to_string(),
365 "keep_theirs".to_string(),
366 ],
367 })),
368 };
369 match resp.result {
370 Some(merge_response::Result::Conflict(c)) => {
371 assert_eq!(c.changeset_id, "cs-001");
372 assert_eq!(c.conflicts.len(), 1);
373 assert_eq!(c.conflicts[0].file_path, "src/lib.rs");
374 assert_eq!(c.conflicts[0].symbols, vec!["process_data"]);
375 assert_eq!(c.conflicts[0].your_agent, "agent-1");
376 assert!(c.conflicts[0].their_agent.is_empty());
377 assert_eq!(c.suggested_action, "adapt");
378 assert_eq!(c.available_actions.len(), 3);
379 }
380 _ => panic!("expected MergeConflict"),
381 }
382 }
383
384 #[test]
385 fn conflict_detail_fields() {
386 let detail = ConflictDetail {
387 file_path: "src/handler.rs".to_string(),
388 symbols: vec!["handle_request".to_string(), "parse_input".to_string()],
389 your_agent: "agent-a".to_string(),
390 their_agent: "agent-b".to_string(),
391 conflict_type: CONFLICT_TYPE_TRUE.to_string(),
392 description: "multiple symbols in conflict".to_string(),
393 };
394 assert_eq!(detail.symbols.len(), 2);
395 assert_eq!(detail.conflict_type, CONFLICT_TYPE_TRUE);
396 }
397
398 #[test]
399 fn sanitize_for_proto_strips_null_bytes() {
400 assert_eq!(sanitize_for_proto("hello\0world"), "helloworld");
401 assert_eq!(sanitize_for_proto("\0\0"), "");
402 assert_eq!(sanitize_for_proto("clean"), "clean");
403 }
404
405 #[test]
406 fn sanitize_for_proto_preserves_valid_utf8() {
407 assert_eq!(sanitize_for_proto("fn résumé()"), "fn résumé()");
409 assert_eq!(sanitize_for_proto("日本語"), "日本語");
410 assert_eq!(sanitize_for_proto("bad\u{FFFD}char"), "bad\u{FFFD}char");
412 }
413}