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
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    // Resolve repo_id_str for enriched events (non-fatal — empty string on failure)
28    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    // Get changeset and verify it's approved
37    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    // Get workspace for this session
48    let ws = engine
49        .workspace_manager()
50        .get_workspace(&sid)
51        .ok_or_else(|| Status::not_found("Workspace not found for session"))?;
52
53    // Get git repo — also use this repo_id for lock release (the first get_repo
54    // call is non-fatal and may return empty string, but this one propagates errors)
55    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    // Capture affected files from workspace overlay before merge/drop
64    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    // Use the programmatic workspace merge instead of git add -A
80    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 workspace guard before further async work
91    drop(ws);
92
93    match merge_result {
94        WorkspaceMergeResult::FastMerge { commit_hash } => {
95            // Release locks first — git commit is already in the tree,
96            // so locks must be freed regardless of changeset-store state.
97            // Idempotent: if locks were already released at submit under
98            // DKOD_RELEASE_ON_SUBMIT, this is a no-op.
99            release_locks_and_emit(server, repo_id, sid, &req.session_id, &changeset_id.to_string()).await;
100
101            // Update changeset status to merged
102            engine.changeset_store().set_merged(changeset_id, &commit_hash).await
103                .map_err(|e| Status::internal(e.to_string()))?;
104
105            // Publish merge event
106            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 first — git commit is already in the tree.
134            // Idempotent w.r.t. a prior release at submit (see FastMerge arm).
135            release_locks_and_emit(server, repo_id, sid, &req.session_id, &changeset_id.to_string()).await;
136
137            // Update changeset status to merged
138            engine.changeset_store().set_merged(changeset_id, &commit_hash).await
139                .map_err(|e| Status::internal(e.to_string()))?;
140
141            // Publish merge event
142            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            // Intentionally NOT releasing locks here. The agent retains its locks
171            // while resolving conflicts (dk_resolve → retry dk_merge). Locks are
172            // released when the session is closed (dk_close) or times out (30 min GC).
173            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                        // TODO: resolve their_agent from the session/changeset store
183                        // once SemanticConflict carries agent attribution.
184                        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
215/// Release all symbol locks for a session and emit `symbol.lock.released`
216/// events so blocked agents can wake up and retry their writes.
217///
218/// Returns the number of locks released, which callers can surface to the
219/// agent (e.g. in the `dk_submit` success response) so parallel-aware flows
220/// have a clear cue that contention was lifted. Idempotent: re-calling
221/// after an earlier release simply returns 0, which makes it safe for both
222/// `handle_submit` (flag-gated early release) and `handle_merge` (always-on
223/// cleanup) to call it.
224pub(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    // Group released locks by file_path for efficient event emission
240    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
269// ── Event type constants ────────────────────────────────────────────
270
271/// Event published when a changeset is successfully merged.
272pub const EVENT_MERGED: &str = "changeset.merged";
273
274/// Event published when symbol locks are released (after merge, close, or timeout).
275/// Blocked agents watch for this to retry their `dk_file_write`.
276pub 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        // Verify the event was renamed from "changeset_merged" to "changeset.merged"
302        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        // their_agent is currently not populated by the server (SemanticConflict
348        // does not carry agent attribution yet), so the test mirrors real
349        // behavior by using an empty string.
350        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        // Multi-byte UTF-8 characters must survive sanitization
409        assert_eq!(sanitize_for_proto("fn résumé()"), "fn résumé()");
410        assert_eq!(sanitize_for_proto("日本語"), "日本語");
411        // Replacement character from String::from_utf8_lossy is valid UTF-8
412        assert_eq!(sanitize_for_proto("bad\u{FFFD}char"), "bad\u{FFFD}char");
413    }
414}