Skip to content

Commit b2adb04

Browse files
matt2eclaude
andauthored
fix: add per-branch mutex to prevent worktree setup race condition (#589)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d8b3ecb commit b2adb04

3 files changed

Lines changed: 94 additions & 33 deletions

File tree

apps/staged/src-tauri/src/actions/commands.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -538,9 +538,18 @@ pub async fn run_prerun_actions(
538538
},
539539
);
540540

541-
let detected = detect_actions_for_repo_context(&github_repo, subpath.as_deref())
542-
.await
543-
.unwrap_or_default();
541+
let detected = match detect_actions_for_repo_context(&github_repo, subpath.as_deref()).await
542+
{
543+
Ok(actions) => actions,
544+
Err(e) => {
545+
log::warn!(
546+
"[run_prerun_actions] action detection failed for repo {} (subpath: {:?}): {e}",
547+
github_repo,
548+
subpath
549+
);
550+
Vec::new()
551+
}
552+
};
544553

545554
let existing_actions = store
546555
.list_repo_actions(&context.id)

apps/staged/src-tauri/src/branches.rs

Lines changed: 77 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ fn cached_workstation_id(workspace_name: &str) -> Option<u64> {
3838
.and_then(|cache| cache.get(workspace_name).copied())
3939
}
4040

41+
/// Per-branch lock that serializes worktree setup so that `setup_worktree`
42+
/// (frontend) and `setup_worktree_sync` (backend/MCP) do not race each other.
43+
/// Follows the same pattern as `REPO_CLONE_LOCKS` in `git/github.rs`.
44+
static WORKTREE_SETUP_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
45+
46+
fn worktree_setup_lock_for(branch_id: &str) -> Arc<Mutex<()>> {
47+
let locks = WORKTREE_SETUP_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
48+
let mut map = locks.lock().unwrap_or_else(|p| p.into_inner());
49+
// Opportunistically drop lock entries that are no longer referenced by any
50+
// active setup operation so the map does not grow without bound.
51+
map.retain(|_, v| Arc::strong_count(v) > 1);
52+
map.entry(branch_id.to_string())
53+
.or_insert_with(|| Arc::new(Mutex::new(())))
54+
.clone()
55+
}
56+
4157
fn get_store(store: &tauri::State<'_, Mutex<Option<Arc<Store>>>>) -> Result<Arc<Store>, String> {
4258
store
4359
.lock()
@@ -823,6 +839,20 @@ pub async fn setup_worktree(
823839
git::project_worktree_path_for(&branch.project_id, &repo_slug, &branch.branch_name)
824840
.map_err(|e| e.to_string())?;
825841

842+
// Serialize worktree creation per branch so that concurrent callers
843+
// (frontend `setup_worktree` vs backend `setup_worktree_sync`) do not race.
844+
let lock = worktree_setup_lock_for(&branch.id);
845+
let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
846+
847+
// Re-check the DB fast-path under the lock — the other caller may have
848+
// finished while we were waiting.
849+
if let Some(existing) = store
850+
.get_workdir_for_branch(&branch.id)
851+
.map_err(|e| e.to_string())?
852+
{
853+
return Ok(to_branch_with_workdir(branch, Some(existing.path)));
854+
}
855+
826856
// Reuse any existing worktree for this branch; otherwise create one.
827857
let existing_worktree_path =
828858
find_existing_worktree_for_branch(&repo_path, &branch.branch_name)?;
@@ -923,6 +953,7 @@ pub async fn setup_worktree(
923953
}
924954
}
925955

956+
// _guard dropped here, releasing the per-branch lock.
926957
Ok(to_branch_with_workdir(branch, Some(worktree_str)))
927958
}
928959

@@ -2109,27 +2140,35 @@ pub(crate) fn setup_worktree_sync(
21092140
crate::git::project_worktree_path_for(&branch.project_id, &repo_slug, &branch.branch_name)
21102141
.map_err(|e| e.to_string())?;
21112142

2143+
// Serialize worktree creation per branch so that concurrent callers
2144+
// (frontend `setup_worktree` vs backend `setup_worktree_sync`) do not race.
2145+
let lock = worktree_setup_lock_for(&branch.id);
2146+
let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
2147+
2148+
// Re-check the DB fast-path under the lock — the other caller may have
2149+
// finished while we were waiting.
2150+
if let Some(existing) = store
2151+
.get_workdir_for_branch(&branch.id)
2152+
.map_err(|e| e.to_string())?
2153+
{
2154+
return Ok(existing.path);
2155+
}
2156+
21122157
emit_progress("creating_worktree", None);
21132158
// Reuse any existing worktree for this branch; otherwise create one.
2114-
let existing_worktree_path = crate::git::list_worktrees(&repo_path)
2115-
.map_err(|e| e.to_string())?
2116-
.into_iter()
2117-
.find_map(|(path, wt_branch)| match wt_branch.as_deref() {
2118-
Some(name) if name == branch.branch_name => Some(path),
2119-
_ => None,
2120-
});
2159+
let existing_worktree_path =
2160+
find_existing_worktree_for_branch(&repo_path, &branch.branch_name)?;
21212161

21222162
let worktree_path = if let Some(path) = existing_worktree_path {
21232163
path
21242164
} else if crate::git::branch_exists(&repo_path, &branch.branch_name)
21252165
.map_err(|e| e.to_string())?
21262166
{
2127-
crate::git::create_worktree_for_existing_branch_at_path(
2167+
create_worktree_for_existing_branch_with_fallback(
21282168
&repo_path,
21292169
&branch.branch_name,
21302170
&desired_worktree_path,
2131-
)
2132-
.map_err(|e| e.to_string())?
2171+
)?
21332172
} else {
21342173
// If the branch exists on the remote (e.g. from an existing PR),
21352174
// start the new local branch from the remote tracking ref so it
@@ -2143,29 +2182,37 @@ pub(crate) fn setup_worktree_sync(
21432182
} else {
21442183
&branch.base_branch
21452184
};
2146-
match crate::git::create_worktree_at_path(
2185+
match create_worktree_with_fallback(
21472186
&repo_path,
21482187
&branch.branch_name,
21492188
start_point,
21502189
&desired_worktree_path,
21512190
) {
21522191
Ok(path) => path,
21532192
Err(create_err) => {
2154-
if crate::git::branch_exists(&repo_path, &branch.branch_name)
2193+
if let Some(path) =
2194+
find_existing_worktree_for_branch(&repo_path, &branch.branch_name)?
2195+
{
2196+
log::warn!(
2197+
"[setup_worktree_sync] Reusing existing worktree '{}' for branch '{}' after create failure",
2198+
path.display(),
2199+
branch.branch_name
2200+
);
2201+
path
2202+
} else if crate::git::branch_exists(&repo_path, &branch.branch_name)
21552203
.map_err(|e| e.to_string())?
21562204
{
21572205
log::warn!(
2158-
"[project_mcp] Branch '{}' already exists after create attempt; retrying with existing branch",
2206+
"[setup_worktree_sync] Branch '{}' already exists after create attempt; retrying with existing branch",
21592207
branch.branch_name
21602208
);
2161-
crate::git::create_worktree_for_existing_branch_at_path(
2209+
create_worktree_for_existing_branch_with_fallback(
21622210
&repo_path,
21632211
&branch.branch_name,
21642212
&desired_worktree_path,
2165-
)
2166-
.map_err(|e| e.to_string())?
2213+
)?
21672214
} else {
2168-
return Err(create_err.to_string());
2215+
return Err(create_err);
21692216
}
21702217
}
21712218
}
@@ -2205,6 +2252,7 @@ pub(crate) fn setup_worktree_sync(
22052252
}
22062253
}
22072254

2255+
// _guard dropped here, releasing the per-branch lock.
22082256
Ok(worktree_str)
22092257
}
22102258

@@ -2249,11 +2297,6 @@ pub(crate) async fn run_prerun_actions_for_branch(
22492297

22502298
// If actions haven't been detected yet for this repo+subpath, detect now
22512299
if !context.has_detected_actions {
2252-
log::info!(
2253-
"[project_mcp] detecting actions for repo {} (subpath: {:?})",
2254-
github_repo,
2255-
subpath
2256-
);
22572300
store
22582301
.set_action_context_detecting(&context.id, true)
22592302
.map_err(|e| format!("Failed to set detection status: {e}"))?;
@@ -2268,12 +2311,22 @@ pub(crate) async fn run_prerun_actions_for_branch(
22682311
);
22692312

22702313
// Run detection (may call out to AI)
2271-
let detected = crate::actions::commands::detect_actions_for_repo_context(
2314+
let detected = match crate::actions::commands::detect_actions_for_repo_context(
22722315
&github_repo,
22732316
subpath.as_deref(),
22742317
)
22752318
.await
2276-
.unwrap_or_default();
2319+
{
2320+
Ok(actions) => actions,
2321+
Err(e) => {
2322+
log::warn!(
2323+
"[run_prerun_actions_for_branch] action detection failed for repo {} (subpath: {:?}): {e}",
2324+
github_repo,
2325+
subpath
2326+
);
2327+
Vec::new()
2328+
}
2329+
};
22772330

22782331
// Persist detected actions (skip duplicates)
22792332
let existing_actions = store
@@ -2375,11 +2428,6 @@ pub(crate) async fn run_prerun_actions_for_branch(
23752428
{
23762429
Ok(_execution_id) => {
23772430
count += 1;
2378-
log::info!(
2379-
"[project_mcp] prerun action '{}' completed for branch {}",
2380-
action.id,
2381-
branch_id
2382-
);
23832431
}
23842432
Err(e) => {
23852433
log::warn!(

apps/staged/src-tauri/src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,6 @@ fn create_project(
448448
};
449449

450450
if is_local {
451-
// Spawn background worktree setup + prerun actions for local branches.
452451
let project_id = project.id.clone();
453452
let store_bg = Arc::clone(&store);
454453
tauri::async_runtime::spawn(async move {
@@ -594,6 +593,11 @@ async fn add_project_repo(
594593
.await?;
595594

596595
// Spawn background worktree + prerun-actions setup — fire and forget.
596+
log::info!(
597+
"[add_project_repo] spawning background setup for repo {} in project {}",
598+
repo.id,
599+
project_id
600+
);
597601
tauri::async_runtime::spawn({
598602
let repo_id = repo.id.clone();
599603
async move {

0 commit comments

Comments
 (0)