mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
Use detached commits when archiving worktrees (#53458)
Don't move the branch when making WIP commits during archiving; instead make detached commits via `write-tree` + `commit-tree`. (No release notes because this isn't stable yet.) Release Notes: - N/A --------- Co-authored-by: Anthony Eid <anthony@zed.dev>
This commit is contained in:
parent
177843c3df
commit
754451598e
7 changed files with 404 additions and 396 deletions
|
|
@ -453,6 +453,30 @@ impl ThreadMetadataStore {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn complete_worktree_restore(
|
||||
&mut self,
|
||||
session_id: &acp::SessionId,
|
||||
path_replacements: &[(PathBuf, PathBuf)],
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(thread) = self.threads.get(session_id).cloned() {
|
||||
let mut paths: Vec<PathBuf> = thread.folder_paths.paths().to_vec();
|
||||
for (old_path, new_path) in path_replacements {
|
||||
for path in &mut paths {
|
||||
if path == old_path {
|
||||
*path = new_path.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
let new_folder_paths = PathList::new(&paths);
|
||||
self.save_internal(ThreadMetadata {
|
||||
folder_paths: new_folder_paths,
|
||||
..thread
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_archived_worktree(
|
||||
&self,
|
||||
worktree_path: String,
|
||||
|
|
@ -2319,6 +2343,97 @@ mod tests {
|
|||
assert_eq!(wt1[0].id, wt2[0].id);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_complete_worktree_restore_multiple_paths(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
||||
|
||||
let original_paths = PathList::new(&[
|
||||
Path::new("/projects/worktree-a"),
|
||||
Path::new("/projects/worktree-b"),
|
||||
Path::new("/other/unrelated"),
|
||||
]);
|
||||
let meta = make_metadata("session-multi", "Multi Thread", Utc::now(), original_paths);
|
||||
|
||||
store.update(cx, |store, cx| {
|
||||
store.save_manually(meta, cx);
|
||||
});
|
||||
|
||||
let replacements = vec![
|
||||
(
|
||||
PathBuf::from("/projects/worktree-a"),
|
||||
PathBuf::from("/restored/worktree-a"),
|
||||
),
|
||||
(
|
||||
PathBuf::from("/projects/worktree-b"),
|
||||
PathBuf::from("/restored/worktree-b"),
|
||||
),
|
||||
];
|
||||
|
||||
store.update(cx, |store, cx| {
|
||||
store.complete_worktree_restore(
|
||||
&acp::SessionId::new("session-multi"),
|
||||
&replacements,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
let entry = store.read_with(cx, |store, _cx| {
|
||||
store.entry(&acp::SessionId::new("session-multi")).cloned()
|
||||
});
|
||||
let entry = entry.unwrap();
|
||||
let paths = entry.folder_paths.paths();
|
||||
assert_eq!(paths.len(), 3);
|
||||
assert!(paths.contains(&PathBuf::from("/restored/worktree-a")));
|
||||
assert!(paths.contains(&PathBuf::from("/restored/worktree-b")));
|
||||
assert!(paths.contains(&PathBuf::from("/other/unrelated")));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_complete_worktree_restore_preserves_unmatched_paths(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
||||
|
||||
let original_paths =
|
||||
PathList::new(&[Path::new("/projects/worktree-a"), Path::new("/other/path")]);
|
||||
let meta = make_metadata("session-partial", "Partial", Utc::now(), original_paths);
|
||||
|
||||
store.update(cx, |store, cx| {
|
||||
store.save_manually(meta, cx);
|
||||
});
|
||||
|
||||
let replacements = vec![
|
||||
(
|
||||
PathBuf::from("/projects/worktree-a"),
|
||||
PathBuf::from("/new/worktree-a"),
|
||||
),
|
||||
(
|
||||
PathBuf::from("/nonexistent/path"),
|
||||
PathBuf::from("/should/not/appear"),
|
||||
),
|
||||
];
|
||||
|
||||
store.update(cx, |store, cx| {
|
||||
store.complete_worktree_restore(
|
||||
&acp::SessionId::new("session-partial"),
|
||||
&replacements,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
let entry = store.read_with(cx, |store, _cx| {
|
||||
store
|
||||
.entry(&acp::SessionId::new("session-partial"))
|
||||
.cloned()
|
||||
});
|
||||
let entry = entry.unwrap();
|
||||
let paths = entry.folder_paths.paths();
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert!(paths.contains(&PathBuf::from("/new/worktree-a")));
|
||||
assert!(paths.contains(&PathBuf::from("/other/path")));
|
||||
assert!(!paths.contains(&PathBuf::from("/should/not/appear")));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_update_restored_worktree_paths_multiple(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ use std::{
|
|||
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use git::repository::{AskPassDelegate, CommitOptions, ResetMode};
|
||||
use gpui::{App, AsyncApp, Entity, Task};
|
||||
use project::{
|
||||
LocalProjectFlags, Project, WorktreeId,
|
||||
|
|
@ -72,17 +71,6 @@ fn archived_worktree_ref_name(id: i64) -> String {
|
|||
format!("refs/archived-worktrees/{}", id)
|
||||
}
|
||||
|
||||
/// The result of a successful [`persist_worktree_state`] call.
|
||||
///
|
||||
/// Carries exactly the information needed to roll back the persist via
|
||||
/// [`rollback_persist`]: the DB row ID (to delete the record and the
|
||||
/// corresponding `refs/archived-worktrees/<id>` git ref) and the staged
|
||||
/// commit hash (to `git reset` back past both WIP commits).
|
||||
pub struct PersistOutcome {
|
||||
pub archived_worktree_id: i64,
|
||||
pub staged_commit_hash: String,
|
||||
}
|
||||
|
||||
/// Builds a [`RootPlan`] for archiving the git worktree at `path`.
|
||||
///
|
||||
/// This is a synchronous planning step that must run *before* any workspace
|
||||
|
|
@ -254,8 +242,12 @@ async fn remove_root_after_worktree_removal(
|
|||
}
|
||||
|
||||
let (repo, _temp_project) = find_or_create_repository(&root.main_repo_path, cx).await?;
|
||||
// force=true is required because the working directory is still dirty
|
||||
// — persist_worktree_state captures state into detached commits without
|
||||
// modifying the real index or working tree, so git refuses to delete
|
||||
// the worktree without --force.
|
||||
let receiver = repo.update(cx, |repo: &mut Repository, _cx| {
|
||||
repo.remove_worktree(root.root_path.clone(), false)
|
||||
repo.remove_worktree(root.root_path.clone(), true)
|
||||
});
|
||||
let result = receiver
|
||||
.await
|
||||
|
|
@ -363,128 +355,38 @@ async fn rollback_root(root: &RootPlan, cx: &mut AsyncApp) {
|
|||
|
||||
/// Saves the worktree's full git state so it can be restored later.
|
||||
///
|
||||
/// This is a multi-step operation:
|
||||
/// 1. Records the original HEAD SHA.
|
||||
/// 2. Creates WIP commit #1 ("staged") capturing the current index.
|
||||
/// 3. Stages everything including untracked files, then creates WIP commit
|
||||
/// #2 ("unstaged") capturing the full working directory.
|
||||
/// 4. Creates a DB record (`ArchivedGitWorktree`) with all the SHAs, the
|
||||
/// branch name, and both paths.
|
||||
/// 5. Links every thread that references this worktree to the DB record.
|
||||
/// 6. Creates a git ref (`refs/archived-worktrees/<id>`) on the main repo
|
||||
/// pointing at the unstaged commit, preventing git from
|
||||
/// garbage-collecting the WIP commits after the worktree is deleted.
|
||||
/// This creates two detached commits (via [`create_archive_checkpoint`] on
|
||||
/// the `GitRepository` trait) that capture the staged and unstaged state
|
||||
/// without moving any branch ref. The commits are:
|
||||
/// - "WIP staged": a tree matching the current index, parented on HEAD
|
||||
/// - "WIP unstaged": a tree with all files (including untracked),
|
||||
/// parented on the staged commit
|
||||
///
|
||||
/// Each step has rollback logic: if step N fails, steps 1..N-1 are undone.
|
||||
/// On success, returns a [`PersistOutcome`] that can be passed to
|
||||
/// [`rollback_persist`] if a later step in the archival pipeline fails.
|
||||
pub async fn persist_worktree_state(root: &RootPlan, cx: &mut AsyncApp) -> Result<PersistOutcome> {
|
||||
/// After creating the commits, this function:
|
||||
/// 1. Records the commit SHAs, branch name, and paths in a DB record.
|
||||
/// 2. Links every thread referencing this worktree to that record.
|
||||
/// 3. Creates a git ref on the main repo to prevent GC of the commits.
|
||||
///
|
||||
/// On success, returns the archived worktree DB row ID for rollback.
|
||||
pub async fn persist_worktree_state(root: &RootPlan, cx: &mut AsyncApp) -> Result<i64> {
|
||||
let (worktree_repo, _temp_worktree_project) = match &root.worktree_repo {
|
||||
Some(worktree_repo) => (worktree_repo.clone(), None),
|
||||
None => find_or_create_repository(&root.root_path, cx).await?,
|
||||
};
|
||||
|
||||
// Read original HEAD SHA before creating any WIP commits
|
||||
let original_commit_hash = worktree_repo
|
||||
.update(cx, |repo, _cx| repo.head_sha())
|
||||
.await
|
||||
.map_err(|_| anyhow!("head_sha canceled"))?
|
||||
.context("failed to read original HEAD SHA")?
|
||||
.context("HEAD SHA is None before WIP commits")?;
|
||||
.context("HEAD SHA is None")?;
|
||||
|
||||
// Create WIP commit #1 (staged state)
|
||||
let askpass = AskPassDelegate::new(cx, |_, _, _| {});
|
||||
let commit_rx = worktree_repo.update(cx, |repo, cx| {
|
||||
repo.commit(
|
||||
"WIP staged".into(),
|
||||
None,
|
||||
CommitOptions {
|
||||
allow_empty: true,
|
||||
..Default::default()
|
||||
},
|
||||
askpass,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
commit_rx
|
||||
// Create two detached WIP commits without moving the branch.
|
||||
let checkpoint_rx = worktree_repo.update(cx, |repo, _cx| repo.create_archive_checkpoint());
|
||||
let (staged_commit_hash, unstaged_commit_hash) = checkpoint_rx
|
||||
.await
|
||||
.map_err(|_| anyhow!("WIP staged commit canceled"))??;
|
||||
|
||||
// Read SHA after staged commit
|
||||
let staged_sha_result = worktree_repo
|
||||
.update(cx, |repo, _cx| repo.head_sha())
|
||||
.await
|
||||
.map_err(|_| anyhow!("head_sha canceled"))
|
||||
.and_then(|r| r.context("failed to read HEAD SHA after staged commit"))
|
||||
.and_then(|opt| opt.context("HEAD SHA is None after staged commit"));
|
||||
let staged_commit_hash = match staged_sha_result {
|
||||
Ok(sha) => sha,
|
||||
Err(error) => {
|
||||
let rx = worktree_repo.update(cx, |repo, cx| {
|
||||
repo.reset("HEAD~1".to_string(), ResetMode::Mixed, cx)
|
||||
});
|
||||
rx.await.ok().and_then(|r| r.log_err());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage all files including untracked
|
||||
let stage_rx = worktree_repo.update(cx, |repo, _cx| repo.stage_all_including_untracked());
|
||||
if let Err(error) = stage_rx
|
||||
.await
|
||||
.map_err(|_| anyhow!("stage all canceled"))
|
||||
.and_then(|inner| inner)
|
||||
{
|
||||
let rx = worktree_repo.update(cx, |repo, cx| {
|
||||
repo.reset("HEAD~1".to_string(), ResetMode::Mixed, cx)
|
||||
});
|
||||
rx.await.ok().and_then(|r| r.log_err());
|
||||
return Err(error.context("failed to stage all files including untracked"));
|
||||
}
|
||||
|
||||
// Create WIP commit #2 (unstaged/untracked state)
|
||||
let askpass = AskPassDelegate::new(cx, |_, _, _| {});
|
||||
let commit_rx = worktree_repo.update(cx, |repo, cx| {
|
||||
repo.commit(
|
||||
"WIP unstaged".into(),
|
||||
None,
|
||||
CommitOptions {
|
||||
allow_empty: true,
|
||||
..Default::default()
|
||||
},
|
||||
askpass,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
if let Err(error) = commit_rx
|
||||
.await
|
||||
.map_err(|_| anyhow!("WIP unstaged commit canceled"))
|
||||
.and_then(|inner| inner)
|
||||
{
|
||||
let rx = worktree_repo.update(cx, |repo, cx| {
|
||||
repo.reset("HEAD~1".to_string(), ResetMode::Mixed, cx)
|
||||
});
|
||||
rx.await.ok().and_then(|r| r.log_err());
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// Read HEAD SHA after WIP commits
|
||||
let head_sha_result = worktree_repo
|
||||
.update(cx, |repo, _cx| repo.head_sha())
|
||||
.await
|
||||
.map_err(|_| anyhow!("head_sha canceled"))
|
||||
.and_then(|r| r.context("failed to read HEAD SHA after WIP commits"))
|
||||
.and_then(|opt| opt.context("HEAD SHA is None after WIP commits"));
|
||||
let unstaged_commit_hash = match head_sha_result {
|
||||
Ok(sha) => sha,
|
||||
Err(error) => {
|
||||
let rx = worktree_repo.update(cx, |repo, cx| {
|
||||
repo.reset(format!("{}~1", staged_commit_hash), ResetMode::Mixed, cx)
|
||||
});
|
||||
rx.await.ok().and_then(|r| r.log_err());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
.map_err(|_| anyhow!("create_archive_checkpoint canceled"))?
|
||||
.context("failed to create archive checkpoint")?;
|
||||
|
||||
// Create DB record
|
||||
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
||||
|
|
@ -516,10 +418,6 @@ pub async fn persist_worktree_state(root: &RootPlan, cx: &mut AsyncApp) -> Resul
|
|||
let archived_worktree_id = match db_result {
|
||||
Ok(id) => id,
|
||||
Err(error) => {
|
||||
let rx = worktree_repo.update(cx, |repo, cx| {
|
||||
repo.reset(format!("{}~1", staged_commit_hash), ResetMode::Mixed, cx)
|
||||
});
|
||||
rx.await.ok().and_then(|r| r.log_err());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
|
@ -557,76 +455,45 @@ pub async fn persist_worktree_state(root: &RootPlan, cx: &mut AsyncApp) -> Resul
|
|||
.await
|
||||
{
|
||||
log::error!(
|
||||
"Failed to delete archived worktree DB record during link rollback: {delete_error:#}"
|
||||
"Failed to delete archived worktree DB record during link rollback: \
|
||||
{delete_error:#}"
|
||||
);
|
||||
}
|
||||
let rx = worktree_repo.update(cx, |repo, cx| {
|
||||
repo.reset(format!("{}~1", staged_commit_hash), ResetMode::Mixed, cx)
|
||||
});
|
||||
rx.await.ok().and_then(|r| r.log_err());
|
||||
return Err(error.context("failed to link thread to archived worktree"));
|
||||
}
|
||||
}
|
||||
|
||||
// Create git ref on main repo (non-fatal)
|
||||
// Create git ref on main repo to prevent GC of the detached commits.
|
||||
// This is fatal: without the ref, git gc will eventually collect the
|
||||
// WIP commits and a later restore will silently fail.
|
||||
let ref_name = archived_worktree_ref_name(archived_worktree_id);
|
||||
let main_repo_result = find_or_create_repository(&root.main_repo_path, cx).await;
|
||||
match main_repo_result {
|
||||
Ok((main_repo, _temp_project)) => {
|
||||
let rx = main_repo.update(cx, |repo, _cx| {
|
||||
repo.update_ref(ref_name.clone(), unstaged_commit_hash.clone())
|
||||
});
|
||||
if let Err(error) = rx
|
||||
.await
|
||||
.map_err(|_| anyhow!("update_ref canceled"))
|
||||
.and_then(|r| r)
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to create ref {} on main repo (non-fatal): {error}",
|
||||
ref_name
|
||||
);
|
||||
}
|
||||
// Keep _temp_project alive until after the await so the headless project isn't dropped mid-operation
|
||||
drop(_temp_project);
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Could not find main repo to create ref {} (non-fatal): {error}",
|
||||
ref_name
|
||||
);
|
||||
}
|
||||
}
|
||||
let (main_repo, _temp_project) = find_or_create_repository(&root.main_repo_path, cx)
|
||||
.await
|
||||
.context("could not open main repo to create archive ref")?;
|
||||
let rx = main_repo.update(cx, |repo, _cx| {
|
||||
repo.update_ref(ref_name.clone(), unstaged_commit_hash.clone())
|
||||
});
|
||||
rx.await
|
||||
.map_err(|_| anyhow!("update_ref canceled"))
|
||||
.and_then(|r| r)
|
||||
.with_context(|| format!("failed to create ref {ref_name} on main repo"))?;
|
||||
drop(_temp_project);
|
||||
|
||||
Ok(PersistOutcome {
|
||||
archived_worktree_id,
|
||||
staged_commit_hash,
|
||||
})
|
||||
Ok(archived_worktree_id)
|
||||
}
|
||||
|
||||
/// Undoes a successful [`persist_worktree_state`] by resetting the WIP
|
||||
/// commits, deleting the git ref on the main repo, and removing the DB
|
||||
/// record.
|
||||
pub async fn rollback_persist(outcome: &PersistOutcome, root: &RootPlan, cx: &mut AsyncApp) {
|
||||
// Undo WIP commits on the worktree repo
|
||||
if let Some(worktree_repo) = &root.worktree_repo {
|
||||
let rx = worktree_repo.update(cx, |repo, cx| {
|
||||
repo.reset(
|
||||
format!("{}~1", outcome.staged_commit_hash),
|
||||
ResetMode::Mixed,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
rx.await.ok().and_then(|r| r.log_err());
|
||||
}
|
||||
|
||||
/// Undoes a successful [`persist_worktree_state`] by deleting the git ref
|
||||
/// on the main repo and removing the DB record. Since the WIP commits are
|
||||
/// detached (they don't move any branch), no git reset is needed — the
|
||||
/// commits will be garbage-collected once the ref is removed.
|
||||
pub async fn rollback_persist(archived_worktree_id: i64, root: &RootPlan, cx: &mut AsyncApp) {
|
||||
// Delete the git ref on main repo
|
||||
if let Ok((main_repo, _temp_project)) =
|
||||
find_or_create_repository(&root.main_repo_path, cx).await
|
||||
{
|
||||
let ref_name = archived_worktree_ref_name(outcome.archived_worktree_id);
|
||||
let ref_name = archived_worktree_ref_name(archived_worktree_id);
|
||||
let rx = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name));
|
||||
rx.await.ok().and_then(|r| r.log_err());
|
||||
// Keep _temp_project alive until after the await so the headless project isn't dropped mid-operation
|
||||
drop(_temp_project);
|
||||
}
|
||||
|
||||
|
|
@ -634,7 +501,7 @@ pub async fn rollback_persist(outcome: &PersistOutcome, root: &RootPlan, cx: &mu
|
|||
let store = cx.update(|cx| ThreadMetadataStore::global(cx));
|
||||
if let Err(error) = store
|
||||
.read_with(cx, |store, cx| {
|
||||
store.delete_archived_worktree(outcome.archived_worktree_id, cx)
|
||||
store.delete_archived_worktree(archived_worktree_id, cx)
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
|
@ -644,183 +511,112 @@ pub async fn rollback_persist(outcome: &PersistOutcome, root: &RootPlan, cx: &mu
|
|||
|
||||
/// Restores a previously archived worktree back to disk from its DB record.
|
||||
///
|
||||
/// Re-creates the git worktree (or adopts an existing directory), resets
|
||||
/// past the two WIP commits to recover the original working directory
|
||||
/// state, verifies HEAD matches the expected commit, and restores the
|
||||
/// original branch if one was recorded.
|
||||
/// Creates the git worktree at the original commit (the branch never moved
|
||||
/// during archival since WIP commits are detached), switches to the branch,
|
||||
/// then uses [`restore_archive_checkpoint`] to reconstruct the staged/
|
||||
/// unstaged state from the WIP commit trees.
|
||||
pub async fn restore_worktree_via_git(
|
||||
row: &ArchivedGitWorktree,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<PathBuf> {
|
||||
let (main_repo, _temp_project) = find_or_create_repository(&row.main_repo_path, cx).await?;
|
||||
|
||||
// Check if worktree path already exists on disk
|
||||
let worktree_path = &row.worktree_path;
|
||||
let app_state = current_app_state(cx).context("no app state available")?;
|
||||
let already_exists = app_state.fs.metadata(worktree_path).await?.is_some();
|
||||
|
||||
if already_exists {
|
||||
let created_new_worktree = if already_exists {
|
||||
let is_git_worktree =
|
||||
resolve_git_worktree_to_main_repo(app_state.fs.as_ref(), worktree_path)
|
||||
.await
|
||||
.is_some();
|
||||
|
||||
if is_git_worktree {
|
||||
// Already a git worktree — another thread on the same worktree
|
||||
// already restored it. Reuse as-is.
|
||||
return Ok(worktree_path.clone());
|
||||
if !is_git_worktree {
|
||||
let rx = main_repo.update(cx, |repo, _cx| repo.repair_worktrees());
|
||||
rx.await
|
||||
.map_err(|_| anyhow!("worktree repair was canceled"))?
|
||||
.context("failed to repair worktrees")?;
|
||||
}
|
||||
|
||||
// Path exists but isn't a git worktree. Ask git to adopt it.
|
||||
let rx = main_repo.update(cx, |repo, _cx| repo.repair_worktrees());
|
||||
rx.await
|
||||
.map_err(|_| anyhow!("worktree repair was canceled"))?
|
||||
.context("failed to repair worktrees")?;
|
||||
false
|
||||
} else {
|
||||
// Create detached worktree at the unstaged commit
|
||||
// Create worktree at the original commit — the branch still points
|
||||
// here because archival used detached commits.
|
||||
let rx = main_repo.update(cx, |repo, _cx| {
|
||||
repo.create_worktree_detached(worktree_path.clone(), row.unstaged_commit_hash.clone())
|
||||
repo.create_worktree_detached(worktree_path.clone(), row.original_commit_hash.clone())
|
||||
});
|
||||
rx.await
|
||||
.map_err(|_| anyhow!("worktree creation was canceled"))?
|
||||
.context("failed to create worktree")?;
|
||||
}
|
||||
true
|
||||
};
|
||||
|
||||
// Get the worktree's repo entity
|
||||
let (wt_repo, _temp_wt_project) = find_or_create_repository(worktree_path, cx).await?;
|
||||
|
||||
// Reset past the WIP commits to recover original state
|
||||
let mixed_reset_ok = {
|
||||
let rx = wt_repo.update(cx, |repo, cx| {
|
||||
repo.reset(row.staged_commit_hash.clone(), ResetMode::Mixed, cx)
|
||||
});
|
||||
match rx.await {
|
||||
Ok(Ok(())) => true,
|
||||
Ok(Err(error)) => {
|
||||
log::error!("Mixed reset to staged commit failed: {error:#}");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("Mixed reset to staged commit was canceled");
|
||||
false
|
||||
}
|
||||
let (wt_repo, _temp_wt_project) = match find_or_create_repository(worktree_path, cx).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
remove_new_worktree_on_error(created_new_worktree, &main_repo, worktree_path, cx).await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let soft_reset_ok = if mixed_reset_ok {
|
||||
let rx = wt_repo.update(cx, |repo, cx| {
|
||||
repo.reset(row.original_commit_hash.clone(), ResetMode::Soft, cx)
|
||||
});
|
||||
match rx.await {
|
||||
Ok(Ok(())) => true,
|
||||
Ok(Err(error)) => {
|
||||
log::error!("Soft reset to original commit failed: {error:#}");
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
log::error!("Soft reset to original commit was canceled");
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// If either WIP reset failed, fall back to a mixed reset directly to
|
||||
// original_commit_hash so we at least land on the right commit.
|
||||
if !mixed_reset_ok || !soft_reset_ok {
|
||||
log::warn!(
|
||||
"WIP reset(s) failed (mixed_ok={mixed_reset_ok}, soft_ok={soft_reset_ok}); \
|
||||
falling back to mixed reset to original commit {}",
|
||||
row.original_commit_hash
|
||||
);
|
||||
let rx = wt_repo.update(cx, |repo, cx| {
|
||||
repo.reset(row.original_commit_hash.clone(), ResetMode::Mixed, cx)
|
||||
});
|
||||
match rx.await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => {
|
||||
return Err(error.context(format!(
|
||||
"fallback reset to original commit {} also failed",
|
||||
row.original_commit_hash
|
||||
)));
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(anyhow!(
|
||||
"fallback reset to original commit {} was canceled",
|
||||
row.original_commit_hash
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify HEAD is at original_commit_hash
|
||||
let current_head = wt_repo
|
||||
.update(cx, |repo, _cx| repo.head_sha())
|
||||
.await
|
||||
.map_err(|_| anyhow!("post-restore head_sha was canceled"))?
|
||||
.context("failed to read HEAD after restore")?
|
||||
.context("HEAD is None after restore")?;
|
||||
|
||||
if current_head != row.original_commit_hash {
|
||||
anyhow::bail!(
|
||||
"After restore, HEAD is at {current_head} but expected {}. \
|
||||
The worktree may be in an inconsistent state.",
|
||||
row.original_commit_hash
|
||||
);
|
||||
}
|
||||
|
||||
// Restore the branch
|
||||
// Switch to the branch. Since the branch was never moved during
|
||||
// archival (WIP commits are detached), it still points at
|
||||
// original_commit_hash, so this is essentially a no-op for HEAD.
|
||||
if let Some(branch_name) = &row.branch_name {
|
||||
// Check if the branch exists and points at original_commit_hash.
|
||||
// If it does, switch to it. If not, create a new branch there.
|
||||
let rx = wt_repo.update(cx, |repo, _cx| repo.change_branch(branch_name.clone()));
|
||||
if matches!(rx.await, Ok(Ok(()))) {
|
||||
// Verify the branch actually points at original_commit_hash after switching
|
||||
let head_after_switch = wt_repo
|
||||
.update(cx, |repo, _cx| repo.head_sha())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.ok())
|
||||
.flatten();
|
||||
|
||||
if head_after_switch.as_deref() != Some(&row.original_commit_hash) {
|
||||
// Branch exists but doesn't point at the right commit.
|
||||
// Switch back to detached HEAD at original_commit_hash.
|
||||
log::warn!(
|
||||
"Branch '{}' exists but points at {:?}, not {}. Creating fresh branch.",
|
||||
branch_name,
|
||||
head_after_switch,
|
||||
row.original_commit_hash
|
||||
);
|
||||
let rx = wt_repo.update(cx, |repo, cx| {
|
||||
repo.reset(row.original_commit_hash.clone(), ResetMode::Mixed, cx)
|
||||
});
|
||||
rx.await.ok().and_then(|r| r.log_err());
|
||||
// Delete the old branch and create fresh
|
||||
let rx = wt_repo.update(cx, |repo, _cx| {
|
||||
repo.create_branch(branch_name.clone(), None)
|
||||
});
|
||||
rx.await.ok().and_then(|r| r.log_err());
|
||||
}
|
||||
} else {
|
||||
// Branch doesn't exist or can't be switched to — create it.
|
||||
if let Err(checkout_error) = rx.await.map_err(|e| anyhow!("{e}")).and_then(|r| r) {
|
||||
log::debug!(
|
||||
"change_branch('{}') failed: {checkout_error:#}, trying create_branch",
|
||||
branch_name
|
||||
);
|
||||
let rx = wt_repo.update(cx, |repo, _cx| {
|
||||
repo.create_branch(branch_name.clone(), None)
|
||||
});
|
||||
if let Ok(Err(error)) | Err(error) = rx.await.map_err(|e| anyhow::anyhow!("{e}")) {
|
||||
if let Ok(Err(error)) | Err(error) = rx.await.map_err(|e| anyhow!("{e}")) {
|
||||
log::warn!(
|
||||
"Could not create branch '{}': {error} — \
|
||||
restored worktree is in detached HEAD state.",
|
||||
restored worktree will be in detached HEAD state.",
|
||||
branch_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the staged/unstaged state from the WIP commit trees.
|
||||
// read-tree --reset -u applies the unstaged tree (including deletions)
|
||||
// to the working directory, then a bare read-tree sets the index to
|
||||
// the staged tree without touching the working directory.
|
||||
let restore_rx = wt_repo.update(cx, |repo, _cx| {
|
||||
repo.restore_archive_checkpoint(
|
||||
row.staged_commit_hash.clone(),
|
||||
row.unstaged_commit_hash.clone(),
|
||||
)
|
||||
});
|
||||
if let Err(error) = restore_rx
|
||||
.await
|
||||
.map_err(|_| anyhow!("restore_archive_checkpoint canceled"))
|
||||
.and_then(|r| r)
|
||||
{
|
||||
remove_new_worktree_on_error(created_new_worktree, &main_repo, worktree_path, cx).await;
|
||||
return Err(error.context("failed to restore archive checkpoint"));
|
||||
}
|
||||
|
||||
Ok(worktree_path.clone())
|
||||
}
|
||||
|
||||
async fn remove_new_worktree_on_error(
|
||||
created_new_worktree: bool,
|
||||
main_repo: &Entity<Repository>,
|
||||
worktree_path: &PathBuf,
|
||||
cx: &mut AsyncApp,
|
||||
) {
|
||||
if created_new_worktree {
|
||||
let rx = main_repo.update(cx, |repo, _cx| {
|
||||
repo.remove_worktree(worktree_path.clone(), true)
|
||||
});
|
||||
rx.await.ok().and_then(|r| r.log_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the git ref and DB records for a single archived worktree.
|
||||
/// Used when an archived worktree is no longer referenced by any thread.
|
||||
pub async fn cleanup_archived_worktree_record(row: &ArchivedGitWorktree, cx: &mut AsyncApp) {
|
||||
|
|
|
|||
|
|
@ -1179,6 +1179,39 @@ impl GitRepository for FakeGitRepository {
|
|||
.boxed()
|
||||
}
|
||||
|
||||
fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>> {
|
||||
let executor = self.executor.clone();
|
||||
let fs = self.fs.clone();
|
||||
let checkpoints = self.checkpoints.clone();
|
||||
let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
|
||||
async move {
|
||||
executor.simulate_random_delay().await;
|
||||
let staged_oid = git::Oid::random(&mut *executor.rng().lock());
|
||||
let unstaged_oid = git::Oid::random(&mut *executor.rng().lock());
|
||||
let entry = fs.entry(&repository_dir_path)?;
|
||||
checkpoints.lock().insert(staged_oid, entry.clone());
|
||||
checkpoints.lock().insert(unstaged_oid, entry);
|
||||
Ok((staged_oid.to_string(), unstaged_oid.to_string()))
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn restore_archive_checkpoint(
|
||||
&self,
|
||||
// The fake filesystem doesn't model a separate index, so only the
|
||||
// unstaged (full working directory) snapshot is restored.
|
||||
_staged_sha: String,
|
||||
unstaged_sha: String,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
match unstaged_sha.parse() {
|
||||
Ok(commit_sha) => self.restore_checkpoint(GitRepositoryCheckpoint { commit_sha }),
|
||||
Err(error) => async move {
|
||||
Err(anyhow::anyhow!(error).context("failed to parse unstaged SHA as Oid"))
|
||||
}
|
||||
.boxed(),
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_checkpoints(
|
||||
&self,
|
||||
left: GitRepositoryCheckpoint,
|
||||
|
|
@ -1380,39 +1413,6 @@ impl GitRepository for FakeGitRepository {
|
|||
async { Ok(()) }.boxed()
|
||||
}
|
||||
|
||||
fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>> {
|
||||
let workdir_path = self.dot_git_path.parent().unwrap();
|
||||
let git_files: Vec<(RepoPath, String)> = self
|
||||
.fs
|
||||
.files()
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let repo_path = path.strip_prefix(workdir_path).ok()?;
|
||||
if repo_path.starts_with(".git") {
|
||||
return None;
|
||||
}
|
||||
let content = self
|
||||
.fs
|
||||
.read_file_sync(path)
|
||||
.ok()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())?;
|
||||
let rel_path = RelPath::new(repo_path, PathStyle::local()).ok()?;
|
||||
Some((RepoPath::from_rel_path(&rel_path), content))
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.with_state_async(true, move |state| {
|
||||
let fs_paths: HashSet<RepoPath> = git_files.iter().map(|(p, _)| p.clone()).collect();
|
||||
for (path, content) in git_files {
|
||||
state.index_contents.insert(path, content);
|
||||
}
|
||||
state
|
||||
.index_contents
|
||||
.retain(|path, _| fs_paths.contains(path));
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_trusted(&self, trusted: bool) {
|
||||
self.is_trusted
|
||||
.store(trusted, std::sync::atomic::Ordering::Release);
|
||||
|
|
|
|||
|
|
@ -916,6 +916,20 @@ pub trait GitRepository: Send + Sync {
|
|||
/// Resets to a previously-created checkpoint.
|
||||
fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
|
||||
|
||||
/// Creates two detached commits capturing the current staged and unstaged
|
||||
/// state without moving any branch. Returns (staged_sha, unstaged_sha).
|
||||
fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>>;
|
||||
|
||||
/// Restores the working directory and index from archive checkpoint SHAs.
|
||||
/// Assumes HEAD is already at the correct commit (original_commit_hash).
|
||||
/// Restores the index to match staged_sha's tree, and the working
|
||||
/// directory to match unstaged_sha's tree.
|
||||
fn restore_archive_checkpoint(
|
||||
&self,
|
||||
staged_sha: String,
|
||||
unstaged_sha: String,
|
||||
) -> BoxFuture<'_, Result<()>>;
|
||||
|
||||
/// Compares two checkpoints, returning true if they are equal
|
||||
fn compare_checkpoints(
|
||||
&self,
|
||||
|
|
@ -959,8 +973,6 @@ pub trait GitRepository: Send + Sync {
|
|||
|
||||
fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>>;
|
||||
|
||||
fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>>;
|
||||
|
||||
fn set_trusted(&self, trusted: bool);
|
||||
fn is_trusted(&self) -> bool;
|
||||
}
|
||||
|
|
@ -2271,18 +2283,6 @@ impl GitRepository for RealGitRepository {
|
|||
.boxed()
|
||||
}
|
||||
|
||||
fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>> {
|
||||
let git_binary = self.git_binary();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let args: Vec<OsString> =
|
||||
vec!["--no-optional-locks".into(), "add".into(), "-A".into()];
|
||||
git_binary?.run(&args).await?;
|
||||
Ok(())
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn push(
|
||||
&self,
|
||||
branch_name: String,
|
||||
|
|
@ -2621,6 +2621,90 @@ impl GitRepository for RealGitRepository {
|
|||
.boxed()
|
||||
}
|
||||
|
||||
fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>> {
|
||||
let git_binary = self.git_binary();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut git = git_binary?.envs(checkpoint_author_envs());
|
||||
|
||||
let head_sha = git
|
||||
.run(&["rev-parse", "HEAD"])
|
||||
.await
|
||||
.context("failed to read HEAD")?;
|
||||
|
||||
// Capture the staged state: write-tree reads the current index
|
||||
let staged_tree = git
|
||||
.run(&["write-tree"])
|
||||
.await
|
||||
.context("failed to write staged tree")?;
|
||||
let staged_sha = git
|
||||
.run(&[
|
||||
"commit-tree",
|
||||
&staged_tree,
|
||||
"-p",
|
||||
&head_sha,
|
||||
"-m",
|
||||
"WIP staged",
|
||||
])
|
||||
.await
|
||||
.context("failed to create staged commit")?;
|
||||
|
||||
// Capture the full state (staged + unstaged + untracked) using
|
||||
// a temporary index so we don't disturb the real one.
|
||||
let unstaged_sha = git
|
||||
.with_temp_index(async |git| {
|
||||
git.run(&["add", "--all"]).await?;
|
||||
let full_tree = git.run(&["write-tree"]).await?;
|
||||
let sha = git
|
||||
.run(&[
|
||||
"commit-tree",
|
||||
&full_tree,
|
||||
"-p",
|
||||
&staged_sha,
|
||||
"-m",
|
||||
"WIP unstaged",
|
||||
])
|
||||
.await?;
|
||||
Ok(sha)
|
||||
})
|
||||
.await
|
||||
.context("failed to create unstaged commit")?;
|
||||
|
||||
Ok((staged_sha, unstaged_sha))
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn restore_archive_checkpoint(
|
||||
&self,
|
||||
staged_sha: String,
|
||||
unstaged_sha: String,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
let git_binary = self.git_binary();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let git = git_binary?;
|
||||
|
||||
// First, set the index AND working tree to match the unstaged
|
||||
// tree. --reset -u computes a tree-level diff between the
|
||||
// current index and unstaged_sha's tree and applies additions,
|
||||
// modifications, and deletions to the working directory.
|
||||
git.run(&["read-tree", "--reset", "-u", &unstaged_sha])
|
||||
.await
|
||||
.context("failed to restore working directory from unstaged commit")?;
|
||||
|
||||
// Then replace just the index with the staged tree. Without -u
|
||||
// this doesn't touch the working directory, so the result is:
|
||||
// working tree = unstaged state, index = staged state.
|
||||
git.run(&["read-tree", &staged_sha])
|
||||
.await
|
||||
.context("failed to restore index from staged commit")?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn compare_checkpoints(
|
||||
&self,
|
||||
left: GitRepositoryCheckpoint,
|
||||
|
|
|
|||
|
|
@ -6021,22 +6021,20 @@ impl Repository {
|
|||
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
|
||||
let (name, commit, use_existing_branch) = match target {
|
||||
CreateWorktreeTarget::ExistingBranch { branch_name } => {
|
||||
(branch_name, None, true)
|
||||
(Some(branch_name), None, true)
|
||||
}
|
||||
CreateWorktreeTarget::NewBranch {
|
||||
branch_name,
|
||||
base_sha: start_point,
|
||||
} => (branch_name, start_point, false),
|
||||
CreateWorktreeTarget::Detached {
|
||||
base_sha: start_point,
|
||||
} => (String::new(), start_point, false),
|
||||
base_sha,
|
||||
} => (Some(branch_name), base_sha, false),
|
||||
CreateWorktreeTarget::Detached { base_sha } => (None, base_sha, false),
|
||||
};
|
||||
|
||||
client
|
||||
.request(proto::GitCreateWorktree {
|
||||
project_id: project_id.0,
|
||||
repository_id: id.to_proto(),
|
||||
name,
|
||||
name: name.unwrap_or_default(),
|
||||
directory: path.to_string_lossy().to_string(),
|
||||
commit,
|
||||
use_existing_branch,
|
||||
|
|
@ -6126,15 +6124,36 @@ impl Repository {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn stage_all_including_untracked(&mut self) -> oneshot::Receiver<Result<()>> {
|
||||
pub fn create_archive_checkpoint(&mut self) -> oneshot::Receiver<Result<(String, String)>> {
|
||||
self.send_job(None, move |repo, _cx| async move {
|
||||
match repo {
|
||||
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
|
||||
backend.stage_all_including_untracked().await
|
||||
backend.create_archive_checkpoint().await
|
||||
}
|
||||
RepositoryState::Remote(_) => {
|
||||
anyhow::bail!(
|
||||
"stage_all_including_untracked is not supported for remote repositories"
|
||||
"create_archive_checkpoint is not supported for remote repositories"
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn restore_archive_checkpoint(
|
||||
&mut self,
|
||||
staged_sha: String,
|
||||
unstaged_sha: String,
|
||||
) -> oneshot::Receiver<Result<()>> {
|
||||
self.send_job(None, move |repo, _cx| async move {
|
||||
match repo {
|
||||
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
|
||||
backend
|
||||
.restore_archive_checkpoint(staged_sha, unstaged_sha)
|
||||
.await
|
||||
}
|
||||
RepositoryState::Remote(_) => {
|
||||
anyhow::bail!(
|
||||
"restore_archive_checkpoint is not supported for remote repositories"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2792,28 +2792,24 @@ impl Sidebar {
|
|||
cancel_rx: smol::channel::Receiver<()>,
|
||||
cx: &mut gpui::AsyncApp,
|
||||
) -> anyhow::Result<ArchiveWorktreeOutcome> {
|
||||
let mut completed_persists: Vec<(
|
||||
thread_worktree_archive::PersistOutcome,
|
||||
thread_worktree_archive::RootPlan,
|
||||
)> = Vec::new();
|
||||
let mut completed_persists: Vec<(i64, thread_worktree_archive::RootPlan)> = Vec::new();
|
||||
|
||||
for root in &roots {
|
||||
if cancel_rx.is_closed() {
|
||||
for (outcome, completed_root) in completed_persists.iter().rev() {
|
||||
thread_worktree_archive::rollback_persist(outcome, completed_root, cx).await;
|
||||
for &(id, ref completed_root) in completed_persists.iter().rev() {
|
||||
thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
|
||||
}
|
||||
return Ok(ArchiveWorktreeOutcome::Cancelled);
|
||||
}
|
||||
|
||||
if root.worktree_repo.is_some() {
|
||||
match thread_worktree_archive::persist_worktree_state(root, cx).await {
|
||||
Ok(outcome) => {
|
||||
completed_persists.push((outcome, root.clone()));
|
||||
Ok(id) => {
|
||||
completed_persists.push((id, root.clone()));
|
||||
}
|
||||
Err(error) => {
|
||||
for (outcome, completed_root) in completed_persists.iter().rev() {
|
||||
thread_worktree_archive::rollback_persist(outcome, completed_root, cx)
|
||||
.await;
|
||||
for &(id, ref completed_root) in completed_persists.iter().rev() {
|
||||
thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
|
|
@ -2821,22 +2817,21 @@ impl Sidebar {
|
|||
}
|
||||
|
||||
if cancel_rx.is_closed() {
|
||||
for (outcome, completed_root) in completed_persists.iter().rev() {
|
||||
thread_worktree_archive::rollback_persist(outcome, completed_root, cx).await;
|
||||
for &(id, ref completed_root) in completed_persists.iter().rev() {
|
||||
thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
|
||||
}
|
||||
return Ok(ArchiveWorktreeOutcome::Cancelled);
|
||||
}
|
||||
|
||||
if let Err(error) = thread_worktree_archive::remove_root(root.clone(), cx).await {
|
||||
if let Some((outcome, completed_root)) = completed_persists.last() {
|
||||
if let Some(&(id, ref completed_root)) = completed_persists.last() {
|
||||
if completed_root.root_path == root.root_path {
|
||||
thread_worktree_archive::rollback_persist(outcome, completed_root, cx)
|
||||
.await;
|
||||
thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
|
||||
completed_persists.pop();
|
||||
}
|
||||
}
|
||||
for (outcome, completed_root) in completed_persists.iter().rev() {
|
||||
thread_worktree_archive::rollback_persist(outcome, completed_root, cx).await;
|
||||
for &(id, ref completed_root) in completed_persists.iter().rev() {
|
||||
thread_worktree_archive::rollback_persist(id, completed_root, cx).await;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4309,11 +4309,10 @@ async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppCon
|
|||
sidebar.archive_thread(&wt_thread_id, window, cx);
|
||||
});
|
||||
|
||||
// archive_thread spawns a chain of tasks:
|
||||
// 1. cx.spawn_in for workspace removal (awaits mw.remove())
|
||||
// 2. start_archive_worktree_task spawns cx.spawn for git persist + disk removal
|
||||
// 3. persist/remove do background_spawn work internally
|
||||
// Each layer needs run_until_parked to drive to completion.
|
||||
// archive_thread spawns a multi-layered chain of tasks (workspace
|
||||
// removal → git persist → disk removal), each of which may spawn
|
||||
// further background work. Each run_until_parked() call drives one
|
||||
// layer of pending work.
|
||||
cx.run_until_parked();
|
||||
cx.run_until_parked();
|
||||
cx.run_until_parked();
|
||||
|
|
|
|||
Loading…
Reference in a new issue