While a new thread's worktree is made, the conversation shows each stage as it runs (Fetch base branch, Check out files, Init submodules, Start agent) with how long it took, Details (branch, base and path), Work locally (send the message in the project folder instead) and Cancel. A clean setup leaves once the agent has the message; a failed or cancelled one stays with what went wrong until the next message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 3 changed files with 341 additions and 32 deletions.
| @@ -21,7 +21,7 @@ use crate::git::{self, TurnChanges}; | ||
| 21 | 21 | use crate::models::{Catalog, Provider}; |
| 22 | 22 | use crate::remote::{ClientMessage, RemoteTask, ThreadAction, ThreadDetail, ThreadSummary, Upload, client::RemoteClient}; |
| 23 | 23 | use crate::settings::{self, BranchNaming, Streaming}; |
| 24 | - use crate::worktree::{self, PullRequest, Worktree, WorktreePlan}; | |
| 24 | + use crate::worktree::{self, PullRequest, SetupStage, StageStatus, Worktree, WorktreePlan}; | |
| 25 | 25 | |
| 26 | 26 | const DEFAULT_TITLE: &str = "New thread"; |
| 27 | 27 | const STDERR_TAIL: usize = 20; |
| @@ -139,6 +139,58 @@ impl TaskStatus { | ||
| 139 | 139 | } |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | + /// How a worktree's setup was called off. | |
| 143 | + #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 144 | + pub enum SetupCancel { | |
| 145 | + /// Don't send the message. | |
| 146 | + Cancel, | |
| 147 | + /// Send it in the project folder instead. | |
| 148 | + Local, | |
| 149 | + } | |
| 150 | + | |
| 151 | + /// A thread's worktree being made for its first message, stage by stage, | |
| 152 | + /// for T3 Code's setup card. | |
| 153 | + pub struct WorktreeSetup { | |
| 154 | + pub stages: Vec<(SetupStage, StageStatus, Option<Instant>, Option<Instant>)>, | |
| 155 | + pub started: Instant, | |
| 156 | + pub base: String, | |
| 157 | + pub branch: Option<String>, | |
| 158 | + pub path: Option<PathBuf>, | |
| 159 | + pub error: Option<String>, | |
| 160 | + pub cancel: Option<SetupCancel>, | |
| 161 | + pub cancelled: bool, | |
| 162 | + } | |
| 163 | + | |
| 164 | + impl WorktreeSetup { | |
| 165 | + fn new(plan: &WorktreePlan) -> Self { | |
| 166 | + Self { | |
| 167 | + stages: SetupStage::ALL.iter().map(|&stage| (stage, StageStatus::Pending, None, None)).collect(), | |
| 168 | + started: Instant::now(), | |
| 169 | + base: plan.base.clone().unwrap_or_else(|| "HEAD".into()), | |
| 170 | + branch: None, | |
| 171 | + path: None, | |
| 172 | + error: None, | |
| 173 | + cancel: None, | |
| 174 | + cancelled: false, | |
| 175 | + } | |
| 176 | + } | |
| 177 | + | |
| 178 | + fn mark(&mut self, stage: SetupStage, status: StageStatus) { | |
| 179 | + let now = Instant::now(); | |
| 180 | + if let Some(entry) = self.stages.iter_mut().find(|entry| entry.0 == stage) { | |
| 181 | + entry.1 = status; | |
| 182 | + match status { | |
| 183 | + StageStatus::Running => entry.2 = Some(now), | |
| 184 | + StageStatus::Done | StageStatus::Failed => { | |
| 185 | + entry.2.get_or_insert(now); | |
| 186 | + entry.3 = Some(now); | |
| 187 | + } | |
| 188 | + StageStatus::Pending | StageStatus::Skipped => {} | |
| 189 | + } | |
| 190 | + } | |
| 191 | + } | |
| 192 | + } | |
| 193 | + | |
| 142 | 194 | /// A command, monitor or subagent Claude left running in the background. The |
| 143 | 195 | /// CLI wakes Claude when it finishes. |
| 144 | 196 | pub struct BackgroundTask { |
| @@ -485,6 +537,9 @@ pub struct Session { | ||
| 485 | 537 | pub interrupted: bool, |
| 486 | 538 | /// Creating the worktree before the first message goes out. |
| 487 | 539 | pub preparing: bool, |
| 540 | + /// The worktree being made for the first message, while it's made (or | |
| 541 | + /// when its setup failed or was cancelled). | |
| 542 | + pub worktree_setup: Option<WorktreeSetup>, | |
| 488 | 543 | /// Tokens in the context window as of the last response. |
| 489 | 544 | pub context_tokens: Option<u64>, |
| 490 | 545 | pub context_window: Option<u64>, |
| @@ -593,6 +648,7 @@ impl Session { | ||
| 593 | 648 | queue: Vec::new(), |
| 594 | 649 | interrupted: false, |
| 595 | 650 | preparing: false, |
| 651 | + worktree_setup: None, | |
| 596 | 652 | context_tokens: None, |
| 597 | 653 | context_window: None, |
| 598 | 654 | background_tasks: Vec::new(), |
| @@ -789,6 +845,9 @@ impl Session { | ||
| 789 | 845 | if text.trim().is_empty() && attachments.is_empty() { |
| 790 | 846 | return; |
| 791 | 847 | } |
| 848 | + if !self.preparing { | |
| 849 | + self.worktree_setup = None; | |
| 850 | + } | |
| 792 | 851 | let names: Vec<SharedString> = attachments.iter().map(|path| file_name(path).into()).collect(); |
| 793 | 852 | let first = !self.items.iter().any(|item| matches!(item, ChatItem::User { .. })); |
| 794 | 853 | let now = SystemTime::now(); |
| @@ -812,28 +871,31 @@ impl Session { | ||
| 812 | 871 | self.preparing = true; |
| 813 | 872 | self.turn_started = Some(Instant::now()); |
| 814 | 873 | self.turn_cost = 0.; |
| 815 | - let task = cx.background_executor().spawn(async move { worktree::create(&plan) }); | |
| 816 | - let first_message = text.clone(); | |
| 874 | + self.worktree_setup = Some(WorktreeSetup::new(&plan)); | |
| 875 | + let (progress_tx, progress_rx) = smol::channel::unbounded(); | |
| 876 | + let task = cx.background_executor().spawn(async move { | |
| 877 | + worktree::create(&plan, &|stage, status| { | |
| 878 | + let _ = progress_tx.try_send((stage, status)); | |
| 879 | + }) | |
| 880 | + }); | |
| 817 | 881 | cx.spawn(async move |this, cx| { |
| 818 | - let created = task.await; | |
| 819 | - let _ = this.update(cx, |this, cx| { | |
| 820 | - this.preparing = false; | |
| 821 | - match created { | |
| 822 | - Ok((worktree, cwd)) => { | |
| 823 | - this.cwd = cwd; | |
| 824 | - this.name_branch(&worktree, first_message, cx); | |
| 825 | - this.worktree = Some(worktree); | |
| 882 | + while let Ok((stage, status)) = progress_rx.recv().await { | |
| 883 | + let alive = this.update(cx, |this, cx| { | |
| 884 | + if let Some(setup) = this.worktree_setup.as_mut() { | |
| 885 | + setup.mark(stage, status); | |
| 886 | + cx.notify(); | |
| 826 | 887 | } |
| 827 | - Err(err) => this.push_notice( | |
| 828 | - NoticeKind::Error, | |
| 829 | - format!("Couldn't create a worktree: {err}\n\nThe thread runs in the project folder instead."), | |
| 830 | - ), | |
| 888 | + }); | |
| 889 | + if alive.is_err() { | |
| 890 | + return; | |
| 831 | 891 | } |
| 832 | - this.start(None, cx); | |
| 833 | - this.snapshot_base(cx); | |
| 834 | - this.deliver(text, attachments, cx); | |
| 835 | - cx.notify(); | |
| 836 | - }); | |
| 892 | + } | |
| 893 | + }) | |
| 894 | + .detach(); | |
| 895 | + let first_message = text.clone(); | |
| 896 | + cx.spawn(async move |this, cx| { | |
| 897 | + let created = task.await; | |
| 898 | + let _ = this.update(cx, |this, cx| this.worktree_created(created, first_message, text, attachments, cx)); | |
| 837 | 899 | }) |
| 838 | 900 | .detach(); |
| 839 | 901 | cx.notify(); |
| @@ -1073,6 +1135,82 @@ impl Session { | ||
| 1073 | 1135 | .detach(); |
| 1074 | 1136 | } |
| 1075 | 1137 | |
| 1138 | + /// Takes the worktree the first message asked for, or where its setup | |
| 1139 | + /// was cancelled or failed, carries on without it; then hands the | |
| 1140 | + /// message to the agent (unless cancelled). | |
| 1141 | + fn worktree_created( | |
| 1142 | + &mut self, | |
| 1143 | + created: Result<(Worktree, PathBuf), String>, | |
| 1144 | + first_message: String, | |
| 1145 | + text: String, | |
| 1146 | + attachments: Vec<PathBuf>, | |
| 1147 | + cx: &mut Context<Self>, | |
| 1148 | + ) { | |
| 1149 | + self.preparing = false; | |
| 1150 | + let cancel = self.worktree_setup.as_ref().and_then(|setup| setup.cancel); | |
| 1151 | + match (created, cancel) { | |
| 1152 | + (Ok((worktree, _)), Some(_)) => { | |
| 1153 | + // Cancelled while git was busy: undo it. | |
| 1154 | + cx.background_executor() | |
| 1155 | + .spawn(async move { | |
| 1156 | + let _ = worktree::remove(&worktree); | |
| 1157 | + let _ = git::git(&worktree.repo, &["branch", "-D", &worktree.branch]); | |
| 1158 | + }) | |
| 1159 | + .detach(); | |
| 1160 | + } | |
| 1161 | + (Ok((worktree, cwd)), None) => { | |
| 1162 | + self.cwd = cwd; | |
| 1163 | + if let Some(setup) = self.worktree_setup.as_mut() { | |
| 1164 | + setup.branch = Some(worktree.branch.clone()); | |
| 1165 | + setup.path = Some(worktree.root.clone()); | |
| 1166 | + } | |
| 1167 | + self.name_branch(&worktree, first_message, cx); | |
| 1168 | + self.worktree = Some(worktree); | |
| 1169 | + } | |
| 1170 | + (Err(err), _) => { | |
| 1171 | + if let Some(setup) = self.worktree_setup.as_mut() { | |
| 1172 | + setup.error = Some(err.clone()); | |
| 1173 | + } | |
| 1174 | + self.push_notice( | |
| 1175 | + NoticeKind::Error, | |
| 1176 | + format!("Couldn't create a worktree: {err}\n\nThe thread runs in the project folder instead."), | |
| 1177 | + ); | |
| 1178 | + } | |
| 1179 | + } | |
| 1180 | + if cancel == Some(SetupCancel::Cancel) { | |
| 1181 | + if let Some(setup) = self.worktree_setup.as_mut() { | |
| 1182 | + setup.cancelled = true; | |
| 1183 | + } | |
| 1184 | + self.status = SessionStatus::Idle; | |
| 1185 | + self.turn_started = None; | |
| 1186 | + self.start(None, cx); | |
| 1187 | + cx.notify(); | |
| 1188 | + return; | |
| 1189 | + } | |
| 1190 | + if let Some(setup) = self.worktree_setup.as_mut() { | |
| 1191 | + setup.mark(SetupStage::Agent, StageStatus::Running); | |
| 1192 | + } | |
| 1193 | + self.start(None, cx); | |
| 1194 | + self.snapshot_base(cx); | |
| 1195 | + self.deliver(text, attachments, cx); | |
| 1196 | + // A clean setup leaves the conversation once the agent has it. | |
| 1197 | + match self.worktree_setup.as_mut() { | |
| 1198 | + Some(setup) if setup.error.is_none() && setup.cancel.is_none() => self.worktree_setup = None, | |
| 1199 | + Some(setup) => setup.mark(SetupStage::Agent, StageStatus::Done), | |
| 1200 | + None => {} | |
| 1201 | + } | |
| 1202 | + cx.notify(); | |
| 1203 | + } | |
| 1204 | + | |
| 1205 | + /// Stops waiting for the worktree: `Cancel` keeps the message unsent, | |
| 1206 | + /// `Local` sends it in the project folder instead. | |
| 1207 | + pub fn cancel_worktree_setup(&mut self, mode: SetupCancel, cx: &mut Context<Self>) { | |
| 1208 | + if let Some(setup) = self.worktree_setup.as_mut().filter(|_| self.preparing) { | |
| 1209 | + setup.cancel = Some(mode); | |
| 1210 | + cx.notify(); | |
| 1211 | + } | |
| 1212 | + } | |
| 1213 | + | |
| 1076 | 1214 | /// Renames a new worktree's temporary branch after the thread's first |
| 1077 | 1215 | /// message, as Settings → Source Control → Worktree branch naming says. |
| 1078 | 1216 | /// The folder keeps its name. |
| @@ -30,8 +30,9 @@ use crate::git; | ||
| 30 | 30 | use crate::models::{Catalog, ModelEntry, SlashCommand}; |
| 31 | 31 | use crate::session::{ |
| 32 | 32 | ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session, SessionEvent, |
| 33 | - TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary, | |
| 33 | + SetupCancel, TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary, | |
| 34 | 34 | }; |
| 35 | + use crate::worktree::StageStatus; | |
| 35 | 36 | use crate::settings::{self, FollowUp, WorkspaceMode}; |
| 36 | 37 | use crate::ui::changes::{self, DiffAction, DiffView}; |
| 37 | 38 | use crate::ui::composer::{self, CONTENT_WIDTH, Density, Tone}; |
| @@ -182,6 +183,8 @@ pub struct ChatView { | ||
| 182 | 183 | /// The composer card's width, measured as it's laid out, so the footer |
| 183 | 184 | /// knows how much fits. |
| 184 | 185 | composer_width: Pixels, |
| 186 | + /// Whether the worktree setup card shows its details. | |
| 187 | + setup_details: bool, | |
| 185 | 188 | /// Whether the traits and access controls folded into "…" last frame. |
| 186 | 189 | folded: std::cell::Cell<(bool, bool)>, |
| 187 | 190 | /// The composer rests on one line while the conversation scrolls. |
| @@ -305,6 +308,7 @@ impl ChatView { | ||
| 305 | 308 | notices_expanded: false, |
| 306 | 309 | tasks_expanded: false, |
| 307 | 310 | composer_width: px(0.), |
| 311 | + setup_details: false, | |
| 308 | 312 | folded: Default::default(), |
| 309 | 313 | collapsed: false, |
| 310 | 314 | _subscriptions: subscriptions, |
| @@ -1271,6 +1275,9 @@ impl ChatView { | ||
| 1271 | 1275 | fn render_status_line(&self, cx: &mut Context<Self>) -> Option<AnyElement> { |
| 1272 | 1276 | let p = metal::palette(cx); |
| 1273 | 1277 | let session = self.session.read(cx); |
| 1278 | + if session.worktree_setup.is_some() { | |
| 1279 | + return Some(self.render_worktree_setup(cx)); | |
| 1280 | + } | |
| 1274 | 1281 | if session.is_working() { |
| 1275 | 1282 | if session.needs_attention() { |
| 1276 | 1283 | return None; |
| @@ -1344,6 +1351,128 @@ impl ChatView { | ||
| 1344 | 1351 | ) |
| 1345 | 1352 | } |
| 1346 | 1353 | |
| 1354 | + /// T3 Code's worktree setup card: the stages of making the thread's | |
| 1355 | + /// worktree as they run, with Details, Work locally and Cancel; or what | |
| 1356 | + /// went wrong once it failed or was cancelled. | |
| 1357 | + fn render_worktree_setup(&self, cx: &mut Context<Self>) -> AnyElement { | |
| 1358 | + let p = metal::palette(cx); | |
| 1359 | + let session = self.session.read(cx); | |
| 1360 | + let Some(setup) = session.worktree_setup.as_ref() else { return div().into_any_element() }; | |
| 1361 | + let running = session.preparing; | |
| 1362 | + let header = if running { | |
| 1363 | + "Setting up worktree…" | |
| 1364 | + } else if setup.cancelled || setup.cancel.is_some() { | |
| 1365 | + "Worktree setup cancelled" | |
| 1366 | + } else if setup.error.is_some() { | |
| 1367 | + "Worktree setup failed" | |
| 1368 | + } else { | |
| 1369 | + "Worktree ready" | |
| 1370 | + }; | |
| 1371 | + let header_color = if setup.error.is_some() && !running { p.red } else { p.ink3 }; | |
| 1372 | + let stage_rows = setup.stages.iter().map(|(stage, status, started, ended)| { | |
| 1373 | + let (icon, color): (AnyElement, Hsla) = match status { | |
| 1374 | + StageStatus::Done => (Icon::new(IconName::Check).size(px(14.)).into_any_element(), p.ink3), | |
| 1375 | + StageStatus::Running => (metal::led(("setup-led", *stage as usize), Led::Live, true, p), p.ink2), | |
| 1376 | + StageStatus::Failed => (Icon::new(IconName::Close).size(px(14.)).into_any_element(), p.red), | |
| 1377 | + StageStatus::Skipped => (Icon::new(IconName::Minus).size(px(14.)).into_any_element(), p.ink3), | |
| 1378 | + StageStatus::Pending => (Icon::new(Lucide::Circle).size(px(14.)).into_any_element(), p.ink3.opacity(0.4)), | |
| 1379 | + }; | |
| 1380 | + let elapsed = started.map(|started| ended.unwrap_or_else(std::time::Instant::now).duration_since(started).as_secs()); | |
| 1381 | + let trailing = match status { | |
| 1382 | + StageStatus::Skipped => Some("skipped".to_string()), | |
| 1383 | + StageStatus::Pending => None, | |
| 1384 | + _ => elapsed.map(format_elapsed), | |
| 1385 | + }; | |
| 1386 | + h_flex() | |
| 1387 | + .h(px(24.)) | |
| 1388 | + .gap(px(10.)) | |
| 1389 | + .text_size(px(13.)) | |
| 1390 | + .text_color(color) | |
| 1391 | + .child(div().w(px(16.)).flex().justify_center().child(icon)) | |
| 1392 | + .child(div().flex_1().min_w_0().truncate().child(stage.label())) | |
| 1393 | + .when_some(trailing, |this, trailing| this.child(div().text_size(px(12.)).text_color(p.ink3).child(trailing))) | |
| 1394 | + }); | |
| 1395 | + let details = self.setup_details.then(|| { | |
| 1396 | + let row = |label: &'static str, value: String| { | |
| 1397 | + h_flex() | |
| 1398 | + .gap(px(12.)) | |
| 1399 | + .child(div().w(px(48.)).text_color(p.ink2).child(label)) | |
| 1400 | + .child(div().flex_1().min_w_0().truncate().font_family(metal::MONO).child(value)) | |
| 1401 | + }; | |
| 1402 | + v_flex() | |
| 1403 | + .ml(px(26.)) | |
| 1404 | + .mt(px(4.)) | |
| 1405 | + .gap(px(2.)) | |
| 1406 | + .text_size(px(12.)) | |
| 1407 | + .text_color(p.ink3) | |
| 1408 | + .when_some(setup.branch.clone(), |this, branch| this.child(row("Branch", branch))) | |
| 1409 | + .child(row("Base", setup.base.clone())) | |
| 1410 | + .when_some(setup.path.clone(), |this, path| this.child(row("Path", display_path(&path)))) | |
| 1411 | + }); | |
| 1412 | + let (cancel, local) = (self.session.clone(), self.session.clone()); | |
| 1413 | + v_flex() | |
| 1414 | + .w_full() | |
| 1415 | + .max_w(px(CONTENT_WIDTH)) | |
| 1416 | + .px(px(16.)) | |
| 1417 | + .pb(px(8.)) | |
| 1418 | + .child( | |
| 1419 | + h_flex() | |
| 1420 | + .h(px(24.)) | |
| 1421 | + .px(px(4.)) | |
| 1422 | + .gap(px(8.)) | |
| 1423 | + .mb(px(4.)) | |
| 1424 | + .text_size(px(14.)) | |
| 1425 | + .text_color(header_color) | |
| 1426 | + .when(running, |this| this.child(metal::led("setup-header-led", Led::Live, true, p))) | |
| 1427 | + .child(header) | |
| 1428 | + .child(div().flex_1()) | |
| 1429 | + .child(div().text_size(px(12.)).text_color(p.ink3).child(format_elapsed(setup.started.elapsed().as_secs()))), | |
| 1430 | + ) | |
| 1431 | + .child(v_flex().px(px(4.)).children(stage_rows)) | |
| 1432 | + .when_some(setup.error.clone().filter(|_| !running), |this, error| { | |
| 1433 | + this.child(div().ml(px(30.)).mt(px(4.)).text_size(px(12.)).text_color(p.ink3).child(error)) | |
| 1434 | + }) | |
| 1435 | + .children(details) | |
| 1436 | + .child( | |
| 1437 | + h_flex() | |
| 1438 | + .ml(px(26.)) | |
| 1439 | + .mt(px(6.)) | |
| 1440 | + .gap(px(4.)) | |
| 1441 | + .child( | |
| 1442 | + MetalButton::new("setup-details") | |
| 1443 | + .ghost() | |
| 1444 | + .mini() | |
| 1445 | + .label("Details") | |
| 1446 | + .on_click(cx.listener(|this, _, _, cx| { | |
| 1447 | + this.setup_details = !this.setup_details; | |
| 1448 | + cx.notify(); | |
| 1449 | + })), | |
| 1450 | + ) | |
| 1451 | + .when(running, |this| { | |
| 1452 | + this.child( | |
| 1453 | + MetalButton::new("setup-local") | |
| 1454 | + .ghost() | |
| 1455 | + .mini() | |
| 1456 | + .icon(Lucide::Laptop) | |
| 1457 | + .label("Work locally") | |
| 1458 | + .on_click(move |_, _, cx| { | |
| 1459 | + local.update(cx, |session, cx| session.cancel_worktree_setup(SetupCancel::Local, cx)) | |
| 1460 | + }), | |
| 1461 | + ) | |
| 1462 | + .child( | |
| 1463 | + MetalButton::new("setup-cancel") | |
| 1464 | + .ghost() | |
| 1465 | + .mini() | |
| 1466 | + .label("Cancel") | |
| 1467 | + .on_click(move |_, _, cx| { | |
| 1468 | + cancel.update(cx, |session, cx| session.cancel_worktree_setup(SetupCancel::Cancel, cx)) | |
| 1469 | + }), | |
| 1470 | + ) | |
| 1471 | + }), | |
| 1472 | + ) | |
| 1473 | + .into_any_element() | |
| 1474 | + } | |
| 1475 | + | |
| 1347 | 1476 | /// A composer picker: a trigger that opens `content` above it. |
| 1348 | 1477 | fn picker( |
| 1349 | 1478 | &self, |
| @@ -32,6 +32,37 @@ pub struct Worktree { | ||
| 32 | 32 | pub repo: PathBuf, |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | + /// A step of making a thread's worktree, as T3 Code's setup card lists them. | |
| 36 | + #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 37 | + pub enum SetupStage { | |
| 38 | + Fetch, | |
| 39 | + Checkout, | |
| 40 | + Submodules, | |
| 41 | + Agent, | |
| 42 | + } | |
| 43 | + | |
| 44 | + impl SetupStage { | |
| 45 | + pub const ALL: [SetupStage; 4] = [SetupStage::Fetch, SetupStage::Checkout, SetupStage::Submodules, SetupStage::Agent]; | |
| 46 | + | |
| 47 | + pub fn label(self) -> &'static str { | |
| 48 | + match self { | |
| 49 | + SetupStage::Fetch => "Fetch base branch", | |
| 50 | + SetupStage::Checkout => "Check out files", | |
| 51 | + SetupStage::Submodules => "Init submodules", | |
| 52 | + SetupStage::Agent => "Start agent", | |
| 53 | + } | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 58 | + pub enum StageStatus { | |
| 59 | + Pending, | |
| 60 | + Running, | |
| 61 | + Done, | |
| 62 | + Failed, | |
| 63 | + Skipped, | |
| 64 | + } | |
| 65 | + | |
| 35 | 66 | /// The prefix of the temporary branch a new worktree starts on, before it's |
| 36 | 67 | /// named after the thread's first message. |
| 37 | 68 | const TEMP_PREFIX: &str = "vectrum"; |
| @@ -54,7 +85,7 @@ pub fn worktrees_dir() -> Option<PathBuf> { | ||
| 54 | 85 | /// (the project's place inside the new checkout). Like T3 Code, it starts |
| 55 | 86 | /// on a temporary `vectrum/<8 hex>` branch in a folder named after it; the |
| 56 | 87 | /// branch is renamed once the thread's first message has named it. |
| 57 | - pub fn create(plan: &WorktreePlan) -> Result<(Worktree, PathBuf), String> { | |
| 88 | + pub fn create(plan: &WorktreePlan, progress: &dyn Fn(SetupStage, StageStatus)) -> Result<(Worktree, PathBuf), String> { | |
| 58 | 89 | let repo = PathBuf::from(git(&plan.project, &["rev-parse", "--show-toplevel"])?.trim()); |
| 59 | 90 | let inside = plan.project.strip_prefix(&repo).map(Path::to_path_buf).unwrap_or_default(); |
| 60 | 91 | let project = repo.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_else(|| "project".into()); |
| @@ -77,19 +108,26 @@ pub fn create(plan: &WorktreePlan) -> Result<(Worktree, PathBuf), String> { | ||
| 77 | 108 | |
| 78 | 109 | let base = plan.base.clone().unwrap_or_else(|| "HEAD".into()); |
| 79 | 110 | let mut start = base.clone(); |
| 80 | - if plan.from_origin && base != "HEAD" && !base.starts_with("origin/") { | |
| 111 | + let has_origin = git(&repo, &["remote"]).is_ok_and(|remotes| remotes.lines().any(|remote| remote == "origin")); | |
| 112 | + if plan.from_origin && has_origin && base != "HEAD" && !base.starts_with("origin/") { | |
| 81 | 113 | // The newest commit origin has for the branch, if origin has it. |
| 82 | - let has_origin = git(&repo, &["remote"]).is_ok_and(|remotes| remotes.lines().any(|remote| remote == "origin")); | |
| 83 | - if has_origin | |
| 84 | - && git(&repo, &["fetch", "--quiet", "origin", &base]).is_ok() | |
| 85 | - && let Ok(sha) = git(&repo, &["rev-parse", "--verify", "-q", &format!("refs/remotes/origin/{base}")]) | |
| 86 | - { | |
| 114 | + progress(SetupStage::Fetch, StageStatus::Running); | |
| 115 | + let fetched = git(&repo, &["fetch", "--quiet", "origin", &base]).is_ok(); | |
| 116 | + if fetched && let Ok(sha) = git(&repo, &["rev-parse", "--verify", "-q", &format!("refs/remotes/origin/{base}")]) { | |
| 87 | 117 | start = sha.trim().to_string(); |
| 88 | 118 | } |
| 119 | + progress(SetupStage::Fetch, if fetched { StageStatus::Done } else { StageStatus::Failed }); | |
| 120 | + } else { | |
| 121 | + progress(SetupStage::Fetch, StageStatus::Skipped); | |
| 89 | 122 | } |
| 90 | 123 | std::fs::create_dir_all(&base_dir).map_err(|err| err.to_string())?; |
| 91 | 124 | let root_arg = root.to_string_lossy().to_string(); |
| 92 | - git(&repo, &["worktree", "add", "--no-track", "-b", &branch, &root_arg, &start])?; | |
| 125 | + progress(SetupStage::Checkout, StageStatus::Running); | |
| 126 | + if let Err(err) = git(&repo, &["worktree", "add", "--no-track", "-b", &branch, &root_arg, &start]) { | |
| 127 | + progress(SetupStage::Checkout, StageStatus::Failed); | |
| 128 | + return Err(err); | |
| 129 | + } | |
| 130 | + progress(SetupStage::Checkout, StageStatus::Done); | |
| 93 | 131 | if base != "HEAD" { |
| 94 | 132 | let merge_base = base.strip_prefix("origin/").unwrap_or(&base); |
| 95 | 133 | let _ = git(&repo, &["config", &format!("branch.{branch}.gh-merge-base"), merge_base]); |
| @@ -103,7 +141,11 @@ pub fn create(plan: &WorktreePlan) -> Result<(Worktree, PathBuf), String> { | ||
| 103 | 141 | if !submodules.is_empty() && root.join(".gitmodules").exists() { |
| 104 | 142 | // Best effort, as in T3 Code: a submodule that won't fetch doesn't |
| 105 | 143 | // stop the thread. |
| 106 | - let _ = git(&root, submodules); | |
| 144 | + progress(SetupStage::Submodules, StageStatus::Running); | |
| 145 | + let fetched = git(&root, submodules).is_ok(); | |
| 146 | + progress(SetupStage::Submodules, if fetched { StageStatus::Done } else { StageStatus::Failed }); | |
| 147 | + } else { | |
| 148 | + progress(SetupStage::Submodules, StageStatus::Skipped); | |
| 107 | 149 | } |
| 108 | 150 | let cwd = root.join(inside); |
| 109 | 151 | Ok((Worktree { root, branch, repo }, cwd)) |
| @@ -242,14 +284,14 @@ mod tests { | ||
| 242 | 284 | from_origin: true, |
| 243 | 285 | submodules: Submodules::Recursive, |
| 244 | 286 | }; |
| 245 | - let (mut worktree, cwd) = create(&plan).unwrap(); | |
| 287 | + let (mut worktree, cwd) = create(&plan, &|_, _| {}).unwrap(); | |
| 246 | 288 | assert!(worktree.branch.starts_with("vectrum/"), "{}", worktree.branch); |
| 247 | 289 | assert!(worktree.root.ends_with(worktree.branch.replace('/', "-"))); |
| 248 | 290 | assert!(cwd.ends_with("sub")); |
| 249 | 291 | assert_eq!(std::fs::read_to_string(cwd.join("file.txt")).unwrap(), "hello\n"); |
| 250 | 292 | |
| 251 | 293 | // A second thread gets its own temporary branch. |
| 252 | - let (mut second, _) = create(&plan).unwrap(); | |
| 294 | + let (mut second, _) = create(&plan, &|_, _| {}).unwrap(); | |
| 253 | 295 | assert_ne!(second.branch, worktree.branch); |
| 254 | 296 | |
| 255 | 297 | // Named after the first message, colliding names are numbered. |