Rust CLI for tangled

Implement stacked PR merge support with conflict detection

Add automatic detection and merging of stacked pull requests with
comprehensive conflict checking and user confirmation. When merging
a PR that's part of a stack, the CLI now:

- Auto-detects stack membership via stack_id field
- Displays preview of all PRs to be merged (current + below)
- Checks cumulative patches for conflicts before merging
- Prompts user for confirmation before executing merge
- Provides clear error messages for conflicts

API changes:
- Add stack fields to Pull: stack_id, change_id, parent_change_id
- Add MergeCheckRequest/Response types for conflict detection
- Add merge_check() method to TangledClient

CLI changes:
- Refactor merge() to detect and handle stacked PRs
- Add helper functions for stack ordering and conflict checking
- Maintain backward compatibility for non-stacked PRs

vitorpy.com b9d979e1 2950b681

verified
Changed files
+393 -46
crates
tangled-api
tangled-cli
src
commands
+55
crates/tangled-api/src/client.rs
··· 1237 1237 Ok(()) 1238 1238 } 1239 1239 1240 + pub async fn merge_check( 1241 + &self, 1242 + repo_did: &str, 1243 + repo_name: &str, 1244 + branch: &str, 1245 + patch: &str, 1246 + pds_base: &str, 1247 + access_jwt: &str, 1248 + ) -> Result<MergeCheckResponse> { 1249 + let sa = self.service_auth_token(pds_base, access_jwt).await?; 1250 + 1251 + let req = MergeCheckRequest { 1252 + did: repo_did.to_string(), 1253 + name: repo_name.to_string(), 1254 + branch: branch.to_string(), 1255 + patch: patch.to_string(), 1256 + }; 1257 + 1258 + self.post_json("sh.tangled.repo.mergeCheck", &req, Some(&sa)) 1259 + .await 1260 + } 1261 + 1240 1262 pub async fn update_repo_spindle( 1241 1263 &self, 1242 1264 did: &str, ··· 1373 1395 pub patch: String, 1374 1396 #[serde(rename = "createdAt")] 1375 1397 pub created_at: String, 1398 + // Stack support fields 1399 + #[serde(skip_serializing_if = "Option::is_none")] 1400 + pub stack_id: Option<String>, 1401 + #[serde(skip_serializing_if = "Option::is_none")] 1402 + pub change_id: Option<String>, 1403 + #[serde(skip_serializing_if = "Option::is_none")] 1404 + pub parent_change_id: Option<String>, 1376 1405 } 1377 1406 1378 1407 #[derive(Debug, Clone)] ··· 1380 1409 pub author_did: String, 1381 1410 pub rkey: String, 1382 1411 pub pull: Pull, 1412 + } 1413 + 1414 + // Merge check types for stacked diff conflict detection 1415 + #[derive(Debug, Clone, Serialize, Deserialize)] 1416 + pub struct MergeCheckRequest { 1417 + pub did: String, 1418 + pub name: String, 1419 + pub branch: String, 1420 + pub patch: String, 1421 + } 1422 + 1423 + #[derive(Debug, Clone, Serialize, Deserialize)] 1424 + pub struct MergeCheckResponse { 1425 + pub is_conflicted: bool, 1426 + #[serde(default)] 1427 + pub conflicts: Vec<ConflictInfo>, 1428 + #[serde(skip_serializing_if = "Option::is_none")] 1429 + pub message: Option<String>, 1430 + #[serde(skip_serializing_if = "Option::is_none")] 1431 + pub error: Option<String>, 1432 + } 1433 + 1434 + #[derive(Debug, Clone, Serialize, Deserialize)] 1435 + pub struct ConflictInfo { 1436 + pub filename: String, 1437 + pub reason: String, 1383 1438 } 1384 1439 1385 1440 #[derive(Debug, Clone)]
+2 -2
crates/tangled-api/src/lib.rs
··· 2 2 3 3 pub use client::TangledClient; 4 4 pub use client::{ 5 - CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, Pull, PullRecord, 6 - RepoRecord, Repository, Secret, 5 + ConflictInfo, CreateRepoOptions, DefaultBranch, Issue, IssueRecord, Language, Languages, 6 + MergeCheckRequest, MergeCheckResponse, Pull, PullRecord, RepoRecord, Repository, Secret, 7 7 };
+336 -44
crates/tangled-cli/src/commands/pr.rs
··· 179 179 .or_else(|| std::env::var("TANGLED_PDS_BASE").ok()) 180 180 .unwrap_or_else(|| "https://bsky.social".into()); 181 181 182 - // Get the PR to find the target repo 182 + // Get the PR 183 183 let pds_client = tangled_api::TangledClient::new(&pds); 184 184 let pull = pds_client 185 185 .get_pull_record(&did, &rkey, Some(session.access_jwt.as_str())) 186 186 .await?; 187 187 188 - // Parse the target repo AT-URI to get did and name 189 - let target_repo = &pull.target.repo; 190 - // Format: at://did:plc:.../sh.tangled.repo/rkey 191 - let parts: Vec<&str> = target_repo.strip_prefix("at://").unwrap_or(target_repo).split('/').collect(); 192 - if parts.len() < 2 { 193 - return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); 194 - } 195 - let repo_did = parts[0]; 188 + // Parse target repo info 189 + let (repo_did, repo_name) = parse_target_repo_info(&pull, &pds_client, &session).await?; 196 190 197 - // Get repo info to find the name 198 - // Parse rkey from target repo AT-URI 199 - let repo_rkey = if parts.len() >= 4 { 200 - parts[3] 191 + // Check if PR is part of a stack 192 + if let Some(stack_id) = &pull.stack_id { 193 + merge_stacked_pr( 194 + &pds_client, 195 + &session, 196 + &pull, 197 + &did, 198 + &rkey, 199 + &repo_did, 200 + &repo_name, 201 + stack_id, 202 + &pds, 203 + ) 204 + .await?; 201 205 } else { 202 - return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); 203 - }; 204 - 205 - #[derive(serde::Deserialize)] 206 - struct Rec { 207 - name: String, 208 - } 209 - #[derive(serde::Deserialize)] 210 - struct GetRes { 211 - value: Rec, 206 + // Single PR merge (existing logic) 207 + merge_single_pr(&session, &did, &rkey, &repo_did, &repo_name, &pds).await?; 212 208 } 213 - let params = [ 214 - ("repo", repo_did.to_string()), 215 - ("collection", "sh.tangled.repo".to_string()), 216 - ("rkey", repo_rkey.to_string()), 217 - ]; 218 - let repo_rec: GetRes = pds_client 219 - .get_json("com.atproto.repo.getRecord", &params, Some(session.access_jwt.as_str())) 220 - .await?; 221 209 222 - // Call merge on the default Tangled API base (tngl.sh) 223 - let api = tangled_api::TangledClient::default(); 224 - api.merge_pull( 225 - &did, 226 - &rkey, 227 - repo_did, 228 - &repo_rec.value.name, 229 - &pds, 230 - &session.access_jwt, 231 - ) 232 - .await?; 233 - 234 - println!("Merged PR {}:{}", did, rkey); 235 210 Ok(()) 236 211 } 237 212 ··· 259 234 } 260 235 Ok((default_did.to_string(), id.to_string())) 261 236 } 237 + 238 + // Helper functions for stacked PR merge support 239 + 240 + async fn merge_single_pr( 241 + session: &tangled_config::session::Session, 242 + did: &str, 243 + rkey: &str, 244 + repo_did: &str, 245 + repo_name: &str, 246 + pds: &str, 247 + ) -> Result<()> { 248 + let api = tangled_api::TangledClient::default(); 249 + api.merge_pull(did, rkey, repo_did, repo_name, pds, &session.access_jwt) 250 + .await?; 251 + 252 + println!("Merged PR {}:{}", did, rkey); 253 + Ok(()) 254 + } 255 + 256 + async fn merge_stacked_pr( 257 + pds_client: &tangled_api::TangledClient, 258 + session: &tangled_config::session::Session, 259 + current_pull: &tangled_api::Pull, 260 + current_did: &str, 261 + current_rkey: &str, 262 + repo_did: &str, 263 + repo_name: &str, 264 + stack_id: &str, 265 + pds: &str, 266 + ) -> Result<()> { 267 + // Step 1: Get full stack 268 + println!("🔍 Detecting stack..."); 269 + let stack = get_stack_pulls(pds_client, &session.did, stack_id, &session.access_jwt).await?; 270 + 271 + if stack.is_empty() { 272 + return Err(anyhow!("Stack is empty")); 273 + } 274 + 275 + // Step 2: Find substack (current PR and all below it) 276 + let substack = find_substack(&stack, current_pull.change_id.as_deref())?; 277 + 278 + println!( 279 + "✓ Detected PR is part of stack (stack has {} total PRs)", 280 + stack.len() 281 + ); 282 + println!(); 283 + println!("The following {} PR(s) will be merged:", substack.len()); 284 + 285 + for (idx, pr) in substack.iter().enumerate() { 286 + let marker = if pr.rkey == current_rkey { 287 + " (current)" 288 + } else { 289 + "" 290 + }; 291 + println!(" [{}] {}: {}{}", idx + 1, pr.rkey, pr.pull.title, marker); 292 + } 293 + println!(); 294 + 295 + // Step 3: Check for conflicts 296 + println!("✓ Checking for conflicts..."); 297 + let api = tangled_api::TangledClient::default(); 298 + let conflicts = check_stack_conflicts( 299 + &api, 300 + repo_did, 301 + repo_name, 302 + &current_pull.target.branch, 303 + &substack, 304 + pds, 305 + &session.access_jwt, 306 + ) 307 + .await?; 308 + 309 + if !conflicts.is_empty() { 310 + println!("✗ Cannot merge: conflicts detected"); 311 + println!(); 312 + for (pr_rkey, conflict_resp) in conflicts { 313 + println!( 314 + " PR {}: Conflicts in {} file(s)", 315 + pr_rkey, 316 + conflict_resp.conflicts.len() 317 + ); 318 + for conflict in conflict_resp.conflicts { 319 + println!(" - {}: {}", conflict.filename, conflict.reason); 320 + } 321 + } 322 + return Err(anyhow!("Stack has merge conflicts")); 323 + } 324 + 325 + println!("✓ All PRs can be merged cleanly"); 326 + println!(); 327 + 328 + // Step 4: Confirmation prompt 329 + if !prompt_confirmation(&format!("Merge {} pull request(s)?", substack.len()))? { 330 + println!("Merge cancelled."); 331 + return Ok(()); 332 + } 333 + 334 + // Step 5: Merge the stack (backend handles combined patch) 335 + println!("Merging {} PR(s)...", substack.len()); 336 + 337 + // Use the current PR's merge endpoint - backend will handle the stack 338 + api.merge_pull( 339 + current_did, 340 + current_rkey, 341 + repo_did, 342 + repo_name, 343 + pds, 344 + &session.access_jwt, 345 + ) 346 + .await?; 347 + 348 + println!("✓ Successfully merged {} pull request(s)", substack.len()); 349 + 350 + Ok(()) 351 + } 352 + 353 + async fn get_stack_pulls( 354 + client: &tangled_api::TangledClient, 355 + user_did: &str, 356 + stack_id: &str, 357 + bearer: &str, 358 + ) -> Result<Vec<tangled_api::PullRecord>> { 359 + // List all user's PRs and filter by stack_id 360 + let all_pulls = client.list_pulls(user_did, None, Some(bearer)).await?; 361 + 362 + let mut stack_pulls: Vec<_> = all_pulls 363 + .into_iter() 364 + .filter(|p| p.pull.stack_id.as_deref() == Some(stack_id)) 365 + .collect(); 366 + 367 + // Order by parent relationships (top to bottom) 368 + order_stack(&mut stack_pulls)?; 369 + 370 + Ok(stack_pulls) 371 + } 372 + 373 + fn order_stack(pulls: &mut Vec<tangled_api::PullRecord>) -> Result<()> { 374 + if pulls.is_empty() { 375 + return Ok(()); 376 + } 377 + 378 + // Build parent map: parent_change_id -> pull 379 + let mut change_id_map: std::collections::HashMap<String, usize> = 380 + std::collections::HashMap::new(); 381 + let mut parent_map: std::collections::HashMap<String, usize> = 382 + std::collections::HashMap::new(); 383 + 384 + for (idx, pr) in pulls.iter().enumerate() { 385 + if let Some(cid) = &pr.pull.change_id { 386 + change_id_map.insert(cid.clone(), idx); 387 + } 388 + if let Some(pcid) = &pr.pull.parent_change_id { 389 + parent_map.insert(pcid.clone(), idx); 390 + } 391 + } 392 + 393 + // Find top of stack (not a parent of any other PR) 394 + let mut top_idx = None; 395 + for (idx, pr) in pulls.iter().enumerate() { 396 + if let Some(cid) = &pr.pull.change_id { 397 + if !parent_map.contains_key(cid) { 398 + top_idx = Some(idx); 399 + break; 400 + } 401 + } 402 + } 403 + 404 + let top_idx = top_idx.ok_or_else(|| anyhow!("Could not find top of stack"))?; 405 + 406 + // Walk down the stack to build ordered list 407 + let mut ordered = Vec::new(); 408 + let mut current_idx = top_idx; 409 + let mut visited = std::collections::HashSet::new(); 410 + 411 + loop { 412 + if visited.contains(&current_idx) { 413 + return Err(anyhow!("Circular dependency in stack")); 414 + } 415 + visited.insert(current_idx); 416 + ordered.push(current_idx); 417 + 418 + // Find child (PR that has this PR as parent) 419 + let current_parent = &pulls[current_idx].pull.parent_change_id; 420 + if current_parent.is_none() { 421 + break; 422 + } 423 + 424 + let next_idx = change_id_map.get(current_parent.as_ref().unwrap()); 425 + 426 + if let Some(&next) = next_idx { 427 + current_idx = next; 428 + } else { 429 + break; 430 + } 431 + } 432 + 433 + // Reorder pulls based on ordered indices 434 + let original = pulls.clone(); 435 + pulls.clear(); 436 + for idx in ordered { 437 + pulls.push(original[idx].clone()); 438 + } 439 + 440 + Ok(()) 441 + } 442 + 443 + fn find_substack<'a>( 444 + stack: &'a [tangled_api::PullRecord], 445 + current_change_id: Option<&str>, 446 + ) -> Result<Vec<&'a tangled_api::PullRecord>> { 447 + let change_id = current_change_id.ok_or_else(|| anyhow!("PR has no change_id"))?; 448 + 449 + let position = stack 450 + .iter() 451 + .position(|p| p.pull.change_id.as_deref() == Some(change_id)) 452 + .ok_or_else(|| anyhow!("PR not found in stack"))?; 453 + 454 + // Return from current position to end (including current) 455 + Ok(stack[position..].iter().collect()) 456 + } 457 + 458 + async fn check_stack_conflicts( 459 + api: &tangled_api::TangledClient, 460 + repo_did: &str, 461 + repo_name: &str, 462 + target_branch: &str, 463 + substack: &[&tangled_api::PullRecord], 464 + pds: &str, 465 + access_jwt: &str, 466 + ) -> Result<Vec<(String, tangled_api::MergeCheckResponse)>> { 467 + let mut conflicts = Vec::new(); 468 + let mut cumulative_patch = String::new(); 469 + 470 + // Check each PR in order (bottom to top of substack) 471 + for pr in substack.iter().rev() { 472 + cumulative_patch.push_str(&pr.pull.patch); 473 + cumulative_patch.push('\n'); 474 + 475 + let check = api 476 + .merge_check( 477 + repo_did, 478 + repo_name, 479 + target_branch, 480 + &cumulative_patch, 481 + pds, 482 + access_jwt, 483 + ) 484 + .await?; 485 + 486 + if check.is_conflicted { 487 + conflicts.push((pr.rkey.clone(), check)); 488 + } 489 + } 490 + 491 + Ok(conflicts) 492 + } 493 + 494 + fn prompt_confirmation(message: &str) -> Result<bool> { 495 + use std::io::{self, Write}; 496 + 497 + print!("{} [y/N]: ", message); 498 + io::stdout().flush()?; 499 + 500 + let mut input = String::new(); 501 + io::stdin().read_line(&mut input)?; 502 + 503 + Ok(matches!( 504 + input.trim().to_lowercase().as_str(), 505 + "y" | "yes" 506 + )) 507 + } 508 + 509 + async fn parse_target_repo_info( 510 + pull: &tangled_api::Pull, 511 + pds_client: &tangled_api::TangledClient, 512 + session: &tangled_config::session::Session, 513 + ) -> Result<(String, String)> { 514 + let target_repo = &pull.target.repo; 515 + let parts: Vec<&str> = target_repo 516 + .strip_prefix("at://") 517 + .unwrap_or(target_repo) 518 + .split('/') 519 + .collect(); 520 + 521 + if parts.len() < 4 { 522 + return Err(anyhow!("Invalid target repo AT-URI: {}", target_repo)); 523 + } 524 + 525 + let repo_did = parts[0].to_string(); 526 + let repo_rkey = parts[3]; 527 + 528 + // Get repo name 529 + #[derive(serde::Deserialize)] 530 + struct Rec { 531 + name: String, 532 + } 533 + #[derive(serde::Deserialize)] 534 + struct GetRes { 535 + value: Rec, 536 + } 537 + 538 + let params = [ 539 + ("repo", repo_did.clone()), 540 + ("collection", "sh.tangled.repo".to_string()), 541 + ("rkey", repo_rkey.to_string()), 542 + ]; 543 + 544 + let repo_rec: GetRes = pds_client 545 + .get_json( 546 + "com.atproto.repo.getRecord", 547 + &params, 548 + Some(&session.access_jwt), 549 + ) 550 + .await?; 551 + 552 + Ok((repo_did, repo_rec.value.name)) 553 + }