···134134 let mut edits = Vec::new();
135135 let client_id = (collab_idx + 1) as u64;
136136137137+ let overlap_count = (edits_per as f64 * overlap) as usize;
138138+137139 for edit_idx in 0..edits_per {
140140+ let is_overlapping = edit_idx < overlap_count;
141141+138142 // Pick a file to edit
139139- let file_idx = if matches!(config.conflict_rate, ConflictRate::Guaranteed) {
140140- // Guaranteed conflict: ALL collaborators edit the same files deterministically.
141141- // Each edit targets file (edit_idx % file_count), so all collaborators
142142- // edit the same set of files in the same order.
143143+ let file_idx = if is_overlapping {
144144+ // Overlapping edit — deterministic file selection so all collaborators
145145+ // target the same files in the same order. This is what produces
146146+ // actual git merge conflicts.
143147 edit_idx % files.len()
144144- } else if edit_idx < (edits_per as f64 * overlap) as usize {
145145- // Overlapping edit — pick from first N files (shared)
146146- rng.random_range(0..std::cmp::max(1, files.len() / 4))
147148 } else {
148149 // Non-overlapping edit — pick from this collaborator's "region"
149150 let start = (collab_idx * files.len()) / config.collaborators;
···160161 let full_path = output_dir.join(path);
161162 let current = std::fs::read_to_string(&full_path).unwrap_or_default();
162163163163- let edited = if matches!(config.conflict_rate, ConflictRate::Guaranteed) {
164164- // For guaranteed conflicts, always edit the same line (first content paragraph).
165165- // Each collaborator writes different text, guaranteeing a git merge conflict.
164164+ let edited = if is_overlapping {
165165+ // Overlapping edits rewrite the same line — guarantees a git conflict
166166+ // when multiple collaborators both edit this file.
166167 apply_conflicting_edit(¤t, collab_idx, edit_idx)
167168 } else {
168169 apply_random_edit(&mut rng, ¤t, collab_idx, edit_idx)
+95-37
src/runner.rs
···916916}
917917918918/// Save a directory to PDS via pds-yrs library (reuses existing client).
919919+/// Also ensures a YrsRepo record exists listing this branch.
919920async fn pds_yrs_save_with_client(
920921 dir: &Path,
921922 client: &pds_yrs::PdsClient,
···925926 let local_state = pds_yrs::LocalState::open(dir)?;
926927 let (rkey, _is_new) =
927928 local_state.ensure_device_rkey(project, client.base_url(), "bench", did)?;
928928- pds_yrs::save(dir, client, did, &rkey, project, None, false).await?;
929929+ pds_yrs::save(dir, client, did, &rkey, false).await?;
930930+ // Ensure the project-level YrsRepo record lists this branch
931931+ ensure_repo_record(client, did, project, &rkey).await?;
932932+ Ok(())
933933+}
934934+935935+/// Create or update the YrsRepo record so it lists the given branch rkey.
936936+async fn ensure_repo_record(
937937+ client: &pds_yrs::PdsClient,
938938+ did: &str,
939939+ project: &str,
940940+ branch_rkey: &str,
941941+) -> Result<(), String> {
942942+ let existing = client
943943+ .get_record(did, pds_yrs::REPO_COLLECTION, project)
944944+ .await?;
945945+946946+ match existing {
947947+ None => {
948948+ let repo = pds_yrs::YrsRepo {
949949+ schema_type: pds_yrs::REPO_COLLECTION.to_string(),
950950+ name: project.to_string(),
951951+ branches: vec![pds_yrs::BranchRef {
952952+ rkey: branch_rkey.to_string(),
953953+ label: None,
954954+ }],
955955+ updated_at: chrono::Utc::now().to_rfc3339(),
956956+ };
957957+ let json = serde_json::to_value(&repo)
958958+ .map_err(|e| format!("serialize YrsRepo: {}", e))?;
959959+ client
960960+ .put_record(did, pds_yrs::REPO_COLLECTION, project, json, None)
961961+ .await?;
962962+ }
963963+ Some(record) => {
964964+ let mut repo: pds_yrs::YrsRepo = serde_json::from_value(record.value)
965965+ .map_err(|e| format!("parse YrsRepo: {}", e))?;
966966+967967+ if repo.branches.iter().any(|b| b.rkey == branch_rkey) {
968968+ return Ok(());
969969+ }
970970+971971+ repo.branches.push(pds_yrs::BranchRef {
972972+ rkey: branch_rkey.to_string(),
973973+ label: None,
974974+ });
975975+ repo.updated_at = chrono::Utc::now().to_rfc3339();
976976+977977+ let json = serde_json::to_value(&repo)
978978+ .map_err(|e| format!("serialize YrsRepo: {}", e))?;
979979+ client
980980+ .put_record(did, pds_yrs::REPO_COLLECTION, project, json, record.cid)
981981+ .await?;
982982+ }
983983+ }
929984 Ok(())
930985}
931986···936991 project: &str,
937992 output_dir: &Path,
938993) -> Result<(), String> {
939939- pds_yrs::merge_project(client, did, project, None, output_dir, false).await
994994+ pds_yrs::merge_project(client, did, project, output_dir, false).await
940995}
941996942997/// Cleanup all PDS repos for a project (best-effort, via curl).
···9451000}
94610019471002/// Cleanup all PDS repos for a project (best-effort).
10031003+/// Deletes the YrsRepo record and all associated YrsBranch records.
9481004fn pds_yrs_cleanup(pds: &PdsConfig, project: &str) -> Result<(), String> {
9491005 let token = get_pds_token(pds)?;
950950- // List all records and delete those matching the project
951951- let list_output = Command::new("curl")
10061006+10071007+ // First, fetch the YrsRepo record to find branch rkeys
10081008+ let repo_output = Command::new("curl")
9521009 .args([
9531010 "-s",
9541011 &format!(
955955- "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection=net.commoninternet.yrsrepo&limit=100",
956956- pds.pds, pds.did
10121012+ "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}",
10131013+ pds.pds, pds.did, pds_yrs::REPO_COLLECTION, project
9571014 ),
9581015 ])
9591016 .output()
960960- .map_err(|e| format!("list records: {}", e))?;
10171017+ .map_err(|e| format!("get repo record: {}", e))?;
9611018962962- if let Ok(text) = String::from_utf8(list_output.stdout) {
10191019+ if let Ok(text) = String::from_utf8(repo_output.stdout) {
9631020 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
964964- if let Some(records) = json["records"].as_array() {
965965- for record in records {
966966- let name = record["value"]["name"].as_str().unwrap_or("");
967967- if name != project {
968968- continue;
969969- }
970970- if let Some(uri) = record["uri"].as_str() {
971971- if let Some(rkey) = uri.rsplit('/').next() {
972972- let _ = Command::new("curl")
973973- .args([
974974- "-s",
975975- "-X",
976976- "POST",
977977- &format!(
978978- "{}/xrpc/com.atproto.repo.deleteRecord",
979979- pds.pds
980980- ),
981981- "-H",
982982- "Content-Type: application/json",
983983- "-H",
984984- &format!("Authorization: Bearer {}", token),
985985- "-d",
986986- &format!(
987987- r#"{{"repo":"{}","collection":"net.commoninternet.yrsrepo","rkey":"{}"}}"#,
988988- pds.did, rkey
989989- ),
990990- ])
991991- .output();
992992- }
10211021+ // Delete each branch record listed in the repo
10221022+ if let Some(branches) = json["value"]["branches"].as_array() {
10231023+ for branch in branches {
10241024+ if let Some(rkey) = branch["rkey"].as_str() {
10251025+ let _ = delete_record_curl(pds, &token, pds_yrs::BRANCH_COLLECTION, rkey);
9931026 }
9941027 }
9951028 }
9961029 }
9971030 }
10311031+10321032+ // Delete the YrsRepo record itself
10331033+ let _ = delete_record_curl(pds, &token, pds_yrs::REPO_COLLECTION, project);
10341034+10351035+ Ok(())
10361036+}
10371037+10381038+fn delete_record_curl(pds: &PdsConfig, token: &str, collection: &str, rkey: &str) -> Result<(), String> {
10391039+ let _ = Command::new("curl")
10401040+ .args([
10411041+ "-s",
10421042+ "-X",
10431043+ "POST",
10441044+ &format!("{}/xrpc/com.atproto.repo.deleteRecord", pds.pds),
10451045+ "-H",
10461046+ "Content-Type: application/json",
10471047+ "-H",
10481048+ &format!("Authorization: Bearer {}", token),
10491049+ "-d",
10501050+ &format!(
10511051+ r#"{{"repo":"{}","collection":"{}","rkey":"{}"}}"#,
10521052+ pds.did, collection, rkey
10531053+ ),
10541054+ ])
10551055+ .output();
9981056 Ok(())
9991057}
10001058