Preserve archived thread project paths after worktree removal (#53893)

When a thread created in a git worktree is archived, the archive flow
removes the linked worktree from the project. If a subsequent thread
event fires after this (e.g. an async title-generation task completing),
`handle_thread_event` recalculates `worktree_paths` from the project's
current state — which no longer includes the removed worktree — and
overwrites the archived thread's stored paths with empty ones.

This causes restoring the thread to prompt the user to re-associate a
project, instead of restoring it directly.

**Fix:** return early from `handle_thread_event` when the thread is
already archived, so the stored paths are never overwritten.

Release Notes:

- Fixed archived threads in git worktrees losing their project
association.
This commit is contained in:
Richard Feldman 2026-04-14 15:05:34 -04:00 committed by GitHub
parent e8c6f29f44
commit bf0fcfbbac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -638,6 +638,10 @@ impl ThreadMetadataStore {
cx: &mut Context<Self>,
) {
if let Some(thread) = self.threads.get(&thread_id) {
debug_assert!(
!thread.archived,
"update_working_directories called on archived thread"
);
self.save_internal(ThreadMetadata {
worktree_paths: WorktreePaths::from_path_lists(
thread.main_worktree_paths().clone(),
@ -664,6 +668,12 @@ impl ThreadMetadataStore {
if thread.worktree_paths == worktree_paths {
continue;
}
// Don't overwrite paths for archived threads — the
// project may no longer include the worktree that was
// removed during the archive flow.
if thread.archived {
continue;
}
self.save_internal(ThreadMetadata {
worktree_paths: worktree_paths.clone(),
..thread.clone()
@ -1102,10 +1112,24 @@ impl ThreadMetadataStore {
let agent_id = thread_ref.connection().agent_id();
let project = thread_ref.project().read(cx);
let worktree_paths = project.worktree_paths(cx);
let remote_connection = project.remote_connection_options(cx);
// Preserve project-dependent fields for archived threads.
// The worktree may already have been removed from the
// project as part of the archive flow, so re-evaluating
// these from the current project state would yield
// empty/incorrect results.
let (worktree_paths, remote_connection) =
if let Some(existing) = existing_thread.filter(|t| t.archived) {
(
existing.worktree_paths.clone(),
existing.remote_connection.clone(),
)
} else {
let project = thread_ref.project().read(cx);
(
project.worktree_paths(cx),
project.remote_connection_options(cx),
)
};
// Threads without a folder path (e.g. started in an empty
// window) are archived by default so they don't get lost,
@ -3543,4 +3567,105 @@ mod tests {
let result = WorktreePaths::from_path_lists(main, folder);
assert!(result.is_err());
}
/// Regression test: archiving a thread created in a git worktree must
/// preserve the thread's folder paths so that restoring it later does
/// not prompt the user to re-associate a project.
#[gpui::test]
async fn test_archived_thread_retains_paths_after_worktree_removal(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/worktrees/feature",
serde_json::json!({ "src": { "main.rs": "" } }),
)
.await;
let project = Project::test(fs, [Path::new("/worktrees/feature")], cx).await;
let connection = StubAgentConnection::new();
let (panel, mut vcx) = setup_panel_with_project(project.clone(), cx);
crate::test_support::open_thread_with_connection(&panel, connection, &mut vcx);
let thread = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap());
let thread_id = crate::test_support::active_thread_id(&panel, &vcx);
// Push content so the event handler saves metadata with the
// project's worktree paths.
thread.update_in(&mut vcx, |thread, _window, cx| {
thread.push_user_content_block(None, "Hello".into(), cx);
});
vcx.run_until_parked();
// Verify paths were saved correctly.
let (folder_paths_before, main_paths_before) = cx.read(|cx| {
let store = ThreadMetadataStore::global(cx).read(cx);
let entry = store.entry(thread_id).unwrap();
assert!(
!entry.folder_paths().is_empty(),
"thread should have folder paths before archiving"
);
(
entry.folder_paths().clone(),
entry.main_worktree_paths().clone(),
)
});
// Archive the thread.
cx.update(|cx| {
ThreadMetadataStore::global(cx).update(cx, |store, cx| {
store.archive(thread_id, None, cx);
});
});
cx.run_until_parked();
// Remove the worktree from the project, simulating what the
// archive flow does for linked git worktrees.
let worktree_id = cx.update(|cx| {
project
.read(cx)
.visible_worktrees(cx)
.next()
.unwrap()
.read(cx)
.id()
});
project.update(cx, |project, cx| {
project.remove_worktree(worktree_id, cx);
});
cx.run_until_parked();
// Trigger a thread event after archiving + worktree removal.
// In production this happens when an async title-generation task
// completes after the thread was archived.
thread.update_in(&mut vcx, |thread, _window, cx| {
thread.set_title("Generated title".into(), cx).detach();
});
vcx.run_until_parked();
// The archived thread must still have its original folder paths.
cx.read(|cx| {
let store = ThreadMetadataStore::global(cx).read(cx);
let entry = store.entry(thread_id).unwrap();
assert!(entry.archived, "thread should still be archived");
assert_eq!(
entry.display_title().as_ref(),
"Generated title",
"title should still be updated for archived threads"
);
assert_eq!(
entry.folder_paths(),
&folder_paths_before,
"archived thread must retain its folder paths after worktree \
removal + subsequent thread event, otherwise restoring it \
will prompt the user to re-associate a project"
);
assert_eq!(
entry.main_worktree_paths(),
&main_paths_before,
"archived thread must retain its main worktree paths after \
worktree removal + subsequent thread event"
);
});
}
}