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