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