diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-18 12:38:37 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-18 13:10:24 +0000 |
| commit | e6bb9effa194fe63b5e969c090dbe6e93f13d312 (patch) | |
| tree | b0d3e93b8d1113af81a8c835417a601e68ca28b6 | |
| parent | eb3860359cfc2f2bab95a546bd6003091584b918 (diff) | |
feat: support PR-format proposals in ngit apply
Instead of erroring when a proposal uses PR format (kind 1618/1619),
ngit apply now fetches the tip commit from git servers, determines the
base commit via the merge-base tag or by walking ahead of main, generates
patch text for each commit using git2, and applies via git am.
Also fixes a bug where clone-tag server hints were silently skipped in
the fetch fallback, and refactors the git-am invocation into a shared
helper to avoid duplication between the patch and PR code paths.
| -rw-r--r-- | src/bin/ngit/sub_commands/apply.rs | 169 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/init.rs | 321 |
2 files changed, 321 insertions, 169 deletions
diff --git a/src/bin/ngit/sub_commands/apply.rs b/src/bin/ngit/sub_commands/apply.rs index 4b13975..3700c37 100644 --- a/src/bin/ngit/sub_commands/apply.rs +++ b/src/bin/ngit/sub_commands/apply.rs | |||
| @@ -1,4 +1,5 @@ | |||
| 1 | use std::{ | 1 | use std::{ |
| 2 | collections::HashSet, | ||
| 2 | io::Write, | 3 | io::Write, |
| 3 | process::{Command, Stdio}, | 4 | process::{Command, Stdio}, |
| 4 | time::Duration, | 5 | time::Duration, |
| @@ -8,7 +9,13 @@ use anyhow::{Context, Result, bail}; | |||
| 8 | use indicatif::{ProgressBar, ProgressStyle}; | 9 | use indicatif::{ProgressBar, ProgressStyle}; |
| 9 | use ngit::{ | 10 | use ngit::{ |
| 10 | client::get_all_proposal_patch_pr_pr_update_events_from_cache, | 11 | client::get_all_proposal_patch_pr_pr_update_events_from_cache, |
| 11 | git_events::get_pr_tip_event_or_most_recent_patch_with_ancestors, | 12 | fetch::fetch_from_git_server, |
| 13 | git::str_to_sha1, | ||
| 14 | git_events::{ | ||
| 15 | KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, | ||
| 16 | get_pr_tip_event_or_most_recent_patch_with_ancestors, tag_value, | ||
| 17 | }, | ||
| 18 | repo_ref::{RepoRef, is_grasp_server_in_list}, | ||
| 12 | }; | 19 | }; |
| 13 | use nostr::nips::nip19::Nip19; | 20 | use nostr::nips::nip19::Nip19; |
| 14 | use nostr_sdk::{EventId, FromBech32}; | 21 | use nostr_sdk::{EventId, FromBech32}; |
| @@ -123,17 +130,15 @@ pub async fn launch(id: &str, stdout: bool, offline: bool) -> Result<()> { | |||
| 123 | let patches = get_pr_tip_event_or_most_recent_patch_with_ancestors(commits_events.clone()) | 130 | let patches = get_pr_tip_event_or_most_recent_patch_with_ancestors(commits_events.clone()) |
| 124 | .context("failed to find any PR or patch events on this proposal")?; | 131 | .context("failed to find any PR or patch events on this proposal")?; |
| 125 | 132 | ||
| 126 | if patches.iter().any(|e| { | 133 | if patches |
| 127 | [ | 134 | .iter() |
| 128 | ngit::git_events::KIND_PULL_REQUEST, | 135 | .any(|e| [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&e.kind)) |
| 129 | ngit::git_events::KIND_PULL_REQUEST_UPDATE, | 136 | { |
| 130 | ] | 137 | let pr_event = patches |
| 131 | .contains(&e.kind) | 138 | .first() |
| 132 | }) { | 139 | .context("patch chain should contain at least one event")?; |
| 133 | bail!( | 140 | apply_pr(&git_repo, &repo_ref, pr_event, stdout)?; |
| 134 | "this proposal uses PR format (not patches). Use `ngit checkout {}` instead.", | 141 | return Ok(()); |
| 135 | event_id.to_hex() | ||
| 136 | ); | ||
| 137 | } | 142 | } |
| 138 | 143 | ||
| 139 | if stdout { | 144 | if stdout { |
| @@ -159,16 +164,124 @@ fn parse_event_id(id: &str) -> Result<EventId> { | |||
| 159 | bail!("invalid event-id or nevent: {id}") | 164 | bail!("invalid event-id or nevent: {id}") |
| 160 | } | 165 | } |
| 161 | 166 | ||
| 162 | fn output_patches_to_stdout(mut patches: Vec<nostr::Event>) { | 167 | fn fetch_oid_for_pr( |
| 163 | patches.reverse(); | 168 | oid: &str, |
| 164 | for patch in patches { | 169 | git_repo: &Repo, |
| 165 | print!("{}\n\n", patch.content); | 170 | repo_ref: &RepoRef, |
| 171 | pr_event: &nostr::Event, | ||
| 172 | ) -> Result<()> { | ||
| 173 | let git_servers = { | ||
| 174 | let mut seen: HashSet<String> = HashSet::new(); | ||
| 175 | let mut out: Vec<String> = vec![]; | ||
| 176 | for tag in pr_event.tags.as_slice() { | ||
| 177 | if tag.kind().eq(&nostr::event::TagKind::Clone) { | ||
| 178 | for clone_url in tag.as_slice().iter().skip(1) { | ||
| 179 | seen.insert(clone_url.clone()); | ||
| 180 | out.push(clone_url.clone()); | ||
| 181 | } | ||
| 182 | } | ||
| 183 | } | ||
| 184 | for server in &repo_ref.git_server { | ||
| 185 | if seen.insert(server.clone()) { | ||
| 186 | out.push(server.clone()); | ||
| 187 | } | ||
| 188 | } | ||
| 189 | out | ||
| 190 | }; | ||
| 191 | |||
| 192 | let term = console::Term::stderr(); | ||
| 193 | for git_server_url in &git_servers { | ||
| 194 | if fetch_from_git_server( | ||
| 195 | git_repo, | ||
| 196 | &[oid.to_string()], | ||
| 197 | git_server_url, | ||
| 198 | &repo_ref.to_nostr_git_url(&None), | ||
| 199 | &term, | ||
| 200 | is_grasp_server_in_list(git_server_url, &repo_ref.grasp_servers()), | ||
| 201 | ) | ||
| 202 | .is_ok() | ||
| 203 | { | ||
| 204 | return Ok(()); | ||
| 205 | } | ||
| 206 | } | ||
| 207 | if !git_repo.does_commit_exist(oid)? { | ||
| 208 | bail!( | ||
| 209 | "cannot find proposal git data from proposal git server hint or repository git servers" | ||
| 210 | ); | ||
| 166 | } | 211 | } |
| 212 | Ok(()) | ||
| 167 | } | 213 | } |
| 168 | 214 | ||
| 169 | fn launch_git_am_with_patches(mut patches: Vec<nostr::Event>) -> Result<()> { | 215 | fn apply_pr( |
| 216 | git_repo: &Repo, | ||
| 217 | repo_ref: &RepoRef, | ||
| 218 | pr_event: &nostr::Event, | ||
| 219 | stdout: bool, | ||
| 220 | ) -> Result<()> { | ||
| 221 | let tip_oid = tag_value(pr_event, "c").context("PR event is missing 'c' (tip commit) tag")?; | ||
| 222 | |||
| 223 | // Ensure the tip commit is available locally | ||
| 224 | if !git_repo.does_commit_exist(&tip_oid)? { | ||
| 225 | fetch_oid_for_pr(&tip_oid, git_repo, repo_ref, pr_event)?; | ||
| 226 | } | ||
| 227 | |||
| 228 | let tip = str_to_sha1(&tip_oid).context("invalid tip commit OID in PR event")?; | ||
| 229 | |||
| 230 | // Determine the base commit: prefer the merge-base tag, fall back to | ||
| 231 | // computing the divergence point from main/master. | ||
| 232 | let base = if let Ok(merge_base_oid) = tag_value(pr_event, "merge-base") { | ||
| 233 | str_to_sha1(&merge_base_oid).context("invalid merge-base OID in PR event")? | ||
| 234 | } else { | ||
| 235 | let (_, main_tip) = git_repo | ||
| 236 | .get_main_or_master_branch() | ||
| 237 | .context("could not determine main branch to compute PR base commit")?; | ||
| 238 | let (ahead, _behind) = git_repo | ||
| 239 | .get_commits_ahead_behind(&main_tip, &tip) | ||
| 240 | .context("failed to compute commits between main and PR tip")?; | ||
| 241 | // ahead is youngest-first; the last element is the oldest PR commit, | ||
| 242 | // whose parent is the effective base. | ||
| 243 | let oldest_pr_commit = ahead | ||
| 244 | .last() | ||
| 245 | .context("no commits found between main and PR tip")?; | ||
| 246 | git_repo | ||
| 247 | .get_commit_parent(oldest_pr_commit) | ||
| 248 | .context("failed to get parent of the oldest PR commit")? | ||
| 249 | }; | ||
| 250 | |||
| 251 | // Collect commits from base..tip (youngest-first from get_commits_ahead_behind) | ||
| 252 | let (commits_youngest_first, _) = git_repo | ||
| 253 | .get_commits_ahead_behind(&base, &tip) | ||
| 254 | .context("failed to enumerate commits in PR")?; | ||
| 255 | |||
| 256 | if commits_youngest_first.is_empty() { | ||
| 257 | bail!("no commits found between base and PR tip"); | ||
| 258 | } | ||
| 259 | |||
| 260 | let total = commits_youngest_first.len() as u64; | ||
| 261 | |||
| 262 | // Generate patches oldest-first | ||
| 263 | let mut patch_texts: Vec<String> = Vec::with_capacity(commits_youngest_first.len()); | ||
| 264 | for (i, commit) in commits_youngest_first.iter().rev().enumerate() { | ||
| 265 | let series_count = Some((i as u64 + 1, total)); | ||
| 266 | let patch = git_repo | ||
| 267 | .make_patch_from_commit(commit, &series_count) | ||
| 268 | .with_context(|| format!("failed to generate patch for commit {commit}"))?; | ||
| 269 | patch_texts.push(patch); | ||
| 270 | } | ||
| 271 | |||
| 272 | if stdout { | ||
| 273 | for patch in &patch_texts { | ||
| 274 | print!("{patch}\n\n"); | ||
| 275 | } | ||
| 276 | } else { | ||
| 277 | apply_patch_texts(patch_texts)?; | ||
| 278 | } | ||
| 279 | |||
| 280 | Ok(()) | ||
| 281 | } | ||
| 282 | |||
| 283 | fn apply_patch_texts(patch_texts: Vec<String>) -> Result<()> { | ||
| 170 | println!("applying to current branch with `git am`"); | 284 | println!("applying to current branch with `git am`"); |
| 171 | patches.reverse(); | ||
| 172 | 285 | ||
| 173 | let mut am = std::process::Command::new("git") | 286 | let mut am = std::process::Command::new("git") |
| 174 | .arg("am") | 287 | .arg("am") |
| @@ -183,15 +296,25 @@ fn launch_git_am_with_patches(mut patches: Vec<nostr::Event>) -> Result<()> { | |||
| 183 | .as_mut() | 296 | .as_mut() |
| 184 | .context("git am process failed to take stdin")?; | 297 | .context("git am process failed to take stdin")?; |
| 185 | 298 | ||
| 186 | for patch in patches { | 299 | for patch in patch_texts { |
| 187 | stdin | 300 | stdin |
| 188 | .write(format!("{}\n\n", patch.content).as_bytes()) | 301 | .write(format!("{patch}\n\n").as_bytes()) |
| 189 | .context("failed to write patch content into git am stdin buffer")?; | 302 | .context("failed to write patch content into git am stdin buffer")?; |
| 190 | } | 303 | } |
| 191 | stdin.flush()?; | 304 | stdin.flush()?; |
| 192 | let output = am | 305 | am.wait_with_output() |
| 193 | .wait_with_output() | ||
| 194 | .context("failed to read git am stdout")?; | 306 | .context("failed to read git am stdout")?; |
| 195 | print!("{:?}", output.stdout); | ||
| 196 | Ok(()) | 307 | Ok(()) |
| 197 | } | 308 | } |
| 309 | |||
| 310 | fn output_patches_to_stdout(mut patches: Vec<nostr::Event>) { | ||
| 311 | patches.reverse(); | ||
| 312 | for patch in patches { | ||
| 313 | print!("{}\n\n", patch.content); | ||
| 314 | } | ||
| 315 | } | ||
| 316 | |||
| 317 | fn launch_git_am_with_patches(mut patches: Vec<nostr::Event>) -> Result<()> { | ||
| 318 | patches.reverse(); | ||
| 319 | apply_patch_texts(patches.into_iter().map(|p| p.content).collect()) | ||
| 320 | } | ||
diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs index 1b577ed..75306d1 100644 --- a/src/bin/ngit/sub_commands/init.rs +++ b/src/bin/ngit/sub_commands/init.rs | |||
| @@ -274,8 +274,7 @@ fn apply_grasp_infrastructure( | |||
| 274 | public_key: &PublicKey, | 274 | public_key: &PublicKey, |
| 275 | identifier: &str, | 275 | identifier: &str, |
| 276 | ) -> Result<()> { | 276 | ) -> Result<()> { |
| 277 | let mut grasp_relay_insert_idx = 0; | 277 | for (grasp_relay_insert_idx, grasp_server) in grasp_servers.iter().enumerate() { |
| 278 | for grasp_server in grasp_servers { | ||
| 279 | // Always add grasp-derived clone URL | 278 | // Always add grasp-derived clone URL |
| 280 | let clone_url = format_grasp_server_url_as_clone_url(grasp_server, public_key, identifier)?; | 279 | let clone_url = format_grasp_server_url_as_clone_url(grasp_server, public_key, identifier)?; |
| 281 | 280 | ||
| @@ -312,7 +311,6 @@ fn apply_grasp_infrastructure( | |||
| 312 | if !relays.contains(&relay_url) { | 311 | if !relays.contains(&relay_url) { |
| 313 | relays.insert(grasp_relay_insert_idx, relay_url); | 312 | relays.insert(grasp_relay_insert_idx, relay_url); |
| 314 | } | 313 | } |
| 315 | grasp_relay_insert_idx += 1; | ||
| 316 | } | 314 | } |
| 317 | Ok(()) | 315 | Ok(()) |
| 318 | } | 316 | } |
| @@ -1713,15 +1711,90 @@ struct DeferredServerFinish { | |||
| 1713 | message: String, | 1711 | message: String, |
| 1714 | } | 1712 | } |
| 1715 | 1713 | ||
| 1716 | /// Coordinates the delayed reveal of per-server detail bars. | ||
| 1717 | /// Bars that finish before the expand timer fires store their final | ||
| 1718 | /// style+message here. The timer applies them all at reveal time so | ||
| 1719 | /// every bar — completed or still waiting — appears in the expanded view. | ||
| 1720 | struct ServerRevealState { | 1714 | struct ServerRevealState { |
| 1721 | revealed: AtomicBool, | 1715 | revealed: AtomicBool, |
| 1722 | deferred: Mutex<Vec<DeferredServerFinish>>, | 1716 | deferred: Mutex<Vec<DeferredServerFinish>>, |
| 1723 | } | 1717 | } |
| 1724 | 1718 | ||
| 1719 | struct PollContext { | ||
| 1720 | timeout_secs: u64, | ||
| 1721 | total: u64, | ||
| 1722 | ready_count: Arc<AtomicU64>, | ||
| 1723 | spinner_pb: ProgressBar, | ||
| 1724 | reveal_state: Arc<ServerRevealState>, | ||
| 1725 | } | ||
| 1726 | |||
| 1727 | fn create_server_bars( | ||
| 1728 | clone_urls: &[String], | ||
| 1729 | detail_multi: &MultiProgress, | ||
| 1730 | ) -> Vec<ProgressBar> { | ||
| 1731 | let waiting_style = ProgressStyle::with_template(" {spinner} {msg}") | ||
| 1732 | .unwrap() | ||
| 1733 | .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"); | ||
| 1734 | clone_urls | ||
| 1735 | .iter() | ||
| 1736 | .map(|url| { | ||
| 1737 | let name = url | ||
| 1738 | .trim_start_matches("https://") | ||
| 1739 | .trim_start_matches("http://") | ||
| 1740 | .to_string(); | ||
| 1741 | detail_multi.add( | ||
| 1742 | ProgressBar::new_spinner() | ||
| 1743 | .with_style(waiting_style.clone()) | ||
| 1744 | .with_message( | ||
| 1745 | console::style(format!("{name} - waiting")) | ||
| 1746 | .for_stderr() | ||
| 1747 | .dim() | ||
| 1748 | .to_string(), | ||
| 1749 | ), | ||
| 1750 | ) | ||
| 1751 | }) | ||
| 1752 | .collect() | ||
| 1753 | } | ||
| 1754 | |||
| 1755 | fn spawn_expand_timer( | ||
| 1756 | expand_delay_ms: u64, | ||
| 1757 | spinner_pb: ProgressBar, | ||
| 1758 | detail_multi: MultiProgress, | ||
| 1759 | heading_bar: ProgressBar, | ||
| 1760 | reveal_state: Arc<ServerRevealState>, | ||
| 1761 | server_bars: Vec<ProgressBar>, | ||
| 1762 | ) -> tokio::task::JoinHandle<()> { | ||
| 1763 | tokio::spawn(async move { | ||
| 1764 | tokio::time::sleep(Duration::from_millis(expand_delay_ms)).await; | ||
| 1765 | spinner_pb.finish_and_clear(); | ||
| 1766 | detail_multi.set_draw_target(ProgressDrawTarget::stderr()); | ||
| 1767 | heading_bar.finish_with_message("waiting for servers to create bare git repo..."); | ||
| 1768 | let mut deferred = reveal_state.deferred.lock().unwrap(); | ||
| 1769 | reveal_state.revealed.store(true, Ordering::Release); | ||
| 1770 | for df in deferred.drain(..) { | ||
| 1771 | df.bar.set_style(df.style); | ||
| 1772 | df.bar.finish_with_message(df.message); | ||
| 1773 | } | ||
| 1774 | for bar in &server_bars { | ||
| 1775 | if !bar.is_finished() { | ||
| 1776 | bar.enable_steady_tick(Duration::from_millis(100)); | ||
| 1777 | } | ||
| 1778 | } | ||
| 1779 | }) | ||
| 1780 | } | ||
| 1781 | |||
| 1782 | fn finalize_spinner( | ||
| 1783 | all_ready: bool, | ||
| 1784 | spinner_pb: &ProgressBar, | ||
| 1785 | final_ready: u64, | ||
| 1786 | total: u64, | ||
| 1787 | ) { | ||
| 1788 | if all_ready { | ||
| 1789 | spinner_pb.finish_and_clear(); | ||
| 1790 | } else { | ||
| 1791 | spinner_pb.set_style(ProgressStyle::with_template("{msg}").unwrap()); | ||
| 1792 | spinner_pb.finish_with_message(format!( | ||
| 1793 | "timed out waiting for servers to create bare git repo ({final_ready}/{total} - complete), proceeding anyway" | ||
| 1794 | )); | ||
| 1795 | } | ||
| 1796 | } | ||
| 1797 | |||
| 1725 | fn finish_server_bar( | 1798 | fn finish_server_bar( |
| 1726 | bar: &ProgressBar, | 1799 | bar: &ProgressBar, |
| 1727 | style: ProgressStyle, | 1800 | style: ProgressStyle, |
| @@ -1745,6 +1818,78 @@ fn finish_server_bar( | |||
| 1745 | } | 1818 | } |
| 1746 | } | 1819 | } |
| 1747 | 1820 | ||
| 1821 | async fn poll_single_server( | ||
| 1822 | url: String, | ||
| 1823 | git_repo_path: std::path::PathBuf, | ||
| 1824 | bar: ProgressBar, | ||
| 1825 | ctx: Arc<PollContext>, | ||
| 1826 | ) -> bool { | ||
| 1827 | let poll_interval = Duration::from_millis(500); | ||
| 1828 | let deadline = tokio::time::Instant::now() + Duration::from_secs(ctx.timeout_secs); | ||
| 1829 | let mut ready = false; | ||
| 1830 | loop { | ||
| 1831 | let is_ready = tokio::task::spawn_blocking({ | ||
| 1832 | let url = url.clone(); | ||
| 1833 | let path = git_repo_path.clone(); | ||
| 1834 | move || check_git_server_ready(&path, &url) | ||
| 1835 | }) | ||
| 1836 | .await | ||
| 1837 | .unwrap_or(false); | ||
| 1838 | |||
| 1839 | if is_ready { | ||
| 1840 | ready = true; | ||
| 1841 | break; | ||
| 1842 | } | ||
| 1843 | |||
| 1844 | if tokio::time::Instant::now() >= deadline { | ||
| 1845 | break; | ||
| 1846 | } | ||
| 1847 | |||
| 1848 | tokio::time::sleep(poll_interval).await; | ||
| 1849 | } | ||
| 1850 | |||
| 1851 | let count = if ready { | ||
| 1852 | ctx.ready_count.fetch_add(1, Ordering::Relaxed) + 1 | ||
| 1853 | } else { | ||
| 1854 | ctx.ready_count.load(Ordering::Relaxed) | ||
| 1855 | }; | ||
| 1856 | |||
| 1857 | ctx.spinner_pb.set_message(format!( | ||
| 1858 | "waiting for servers to create bare git repo... ({count}/{total} - complete)", | ||
| 1859 | total = ctx.total | ||
| 1860 | )); | ||
| 1861 | |||
| 1862 | let name = url | ||
| 1863 | .trim_start_matches("https://") | ||
| 1864 | .trim_start_matches("http://") | ||
| 1865 | .to_string(); | ||
| 1866 | if ready { | ||
| 1867 | let style = ProgressStyle::with_template(&format!( | ||
| 1868 | " {} {{msg}}", | ||
| 1869 | console::style("✔").for_stderr().green() | ||
| 1870 | )) | ||
| 1871 | .unwrap(); | ||
| 1872 | let msg = console::style(format!("{name} - ready")) | ||
| 1873 | .for_stderr() | ||
| 1874 | .green() | ||
| 1875 | .to_string(); | ||
| 1876 | finish_server_bar(&bar, style, msg, &ctx.reveal_state); | ||
| 1877 | } else { | ||
| 1878 | let style = ProgressStyle::with_template(&format!( | ||
| 1879 | " {} {{msg}}", | ||
| 1880 | console::style("✘").for_stderr().red() | ||
| 1881 | )) | ||
| 1882 | .unwrap(); | ||
| 1883 | let msg = console::style(format!("{name} - timeout")) | ||
| 1884 | .for_stderr() | ||
| 1885 | .red() | ||
| 1886 | .to_string(); | ||
| 1887 | finish_server_bar(&bar, style, msg, &ctx.reveal_state); | ||
| 1888 | } | ||
| 1889 | |||
| 1890 | ready | ||
| 1891 | } | ||
| 1892 | |||
| 1748 | /// Poll grasp servers in parallel until all are ready or timeout is reached. | 1893 | /// Poll grasp servers in parallel until all are ready or timeout is reached. |
| 1749 | /// | 1894 | /// |
| 1750 | /// Shows a concise spinner with `x/y - complete` progress. After 5s without | 1895 | /// Shows a concise spinner with `x/y - complete` progress. After 5s without |
| @@ -1799,166 +1944,50 @@ async fn wait_for_grasp_servers( | |||
| 1799 | deferred: Mutex::new(Vec::new()), | 1944 | deferred: Mutex::new(Vec::new()), |
| 1800 | }); | 1945 | }); |
| 1801 | 1946 | ||
| 1802 | // Per-server spinner bars (added to hidden detail_multi) | 1947 | let server_bars = create_server_bars(&clone_urls, &detail_multi); |
| 1803 | let waiting_style = ProgressStyle::with_template(" {spinner} {msg}") | ||
| 1804 | .unwrap() | ||
| 1805 | .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈"); | ||
| 1806 | let server_bars: Vec<ProgressBar> = clone_urls | ||
| 1807 | .iter() | ||
| 1808 | .map(|url| { | ||
| 1809 | let name = url | ||
| 1810 | .trim_start_matches("https://") | ||
| 1811 | .trim_start_matches("http://") | ||
| 1812 | .to_string(); | ||
| 1813 | detail_multi.add( | ||
| 1814 | ProgressBar::new_spinner() | ||
| 1815 | .with_style(waiting_style.clone()) | ||
| 1816 | .with_message( | ||
| 1817 | console::style(format!("{name} - waiting")) | ||
| 1818 | .for_stderr() | ||
| 1819 | .dim() | ||
| 1820 | .to_string(), | ||
| 1821 | ), | ||
| 1822 | ) | ||
| 1823 | }) | ||
| 1824 | .collect(); | ||
| 1825 | 1948 | ||
| 1826 | // Background timer: after expand_delay_ms reveal the detail view and | 1949 | let timer_handle = spawn_expand_timer( |
| 1827 | // flush any bars that already finished (the BarRevealState pattern). | 1950 | expand_delay_ms, |
| 1828 | let detail_multi_for_timer = detail_multi.clone(); | 1951 | spinner_pb.clone(), |
| 1829 | let spinner_for_timer = spinner_pb.clone(); | 1952 | detail_multi.clone(), |
| 1830 | let reveal_state_for_timer = reveal_state.clone(); | 1953 | heading_bar, |
| 1831 | let server_bars_for_timer = server_bars.clone(); | 1954 | reveal_state.clone(), |
| 1832 | let heading_bar_for_timer = heading_bar.clone(); | 1955 | server_bars.clone(), |
| 1833 | let timer_handle = tokio::spawn(async move { | 1956 | ); |
| 1834 | tokio::time::sleep(Duration::from_millis(expand_delay_ms)).await; | ||
| 1835 | spinner_for_timer.finish_and_clear(); | ||
| 1836 | detail_multi_for_timer.set_draw_target(ProgressDrawTarget::stderr()); | ||
| 1837 | // Show the heading in the expanded view. | ||
| 1838 | heading_bar_for_timer.finish_with_message("waiting for servers to create bare git repo..."); | ||
| 1839 | // Lock deferred list, mark revealed, and flush bars that already | ||
| 1840 | // finished. Must hold the lock across the revealed.store so that | ||
| 1841 | // finish_server_bar cannot push after the drain. | ||
| 1842 | let mut deferred = reveal_state_for_timer.deferred.lock().unwrap(); | ||
| 1843 | reveal_state_for_timer | ||
| 1844 | .revealed | ||
| 1845 | .store(true, Ordering::Release); | ||
| 1846 | for df in deferred.drain(..) { | ||
| 1847 | df.bar.set_style(df.style); | ||
| 1848 | df.bar.finish_with_message(df.message); | ||
| 1849 | } | ||
| 1850 | // Kick still-waiting bars into drawing by enabling their tick. | ||
| 1851 | for bar in &server_bars_for_timer { | ||
| 1852 | if !bar.is_finished() { | ||
| 1853 | bar.enable_steady_tick(Duration::from_millis(100)); | ||
| 1854 | } | ||
| 1855 | } | ||
| 1856 | }); | ||
| 1857 | 1957 | ||
| 1858 | // Poll each server in parallel | 1958 | // Poll each server in parallel |
| 1859 | let git_repo_path = git_repo.get_path()?.to_path_buf(); | 1959 | let git_repo_path = git_repo.get_path()?.to_path_buf(); |
| 1960 | let poll_ctx = Arc::new(PollContext { | ||
| 1961 | timeout_secs, | ||
| 1962 | total, | ||
| 1963 | ready_count: ready_count.clone(), | ||
| 1964 | spinner_pb: spinner_pb.clone(), | ||
| 1965 | reveal_state: reveal_state.clone(), | ||
| 1966 | }); | ||
| 1860 | let futures: Vec<_> = clone_urls | 1967 | let futures: Vec<_> = clone_urls |
| 1861 | .iter() | 1968 | .iter() |
| 1862 | .enumerate() | 1969 | .enumerate() |
| 1863 | .map(|(i, url)| { | 1970 | .map(|(i, url)| { |
| 1864 | let url = url.clone(); | 1971 | poll_single_server( |
| 1865 | let ready_count = ready_count.clone(); | 1972 | url.clone(), |
| 1866 | let spinner_pb = spinner_pb.clone(); | 1973 | git_repo_path.clone(), |
| 1867 | let bar = server_bars[i].clone(); | 1974 | server_bars[i].clone(), |
| 1868 | let git_repo_path = git_repo_path.clone(); | 1975 | poll_ctx.clone(), |
| 1869 | let reveal_state = reveal_state.clone(); | 1976 | ) |
| 1870 | async move { | ||
| 1871 | let poll_interval = Duration::from_millis(500); | ||
| 1872 | let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); | ||
| 1873 | let mut ready = false; | ||
| 1874 | loop { | ||
| 1875 | let is_ready = tokio::task::spawn_blocking({ | ||
| 1876 | let url = url.clone(); | ||
| 1877 | let path = git_repo_path.clone(); | ||
| 1878 | move || check_git_server_ready(&path, &url) | ||
| 1879 | }) | ||
| 1880 | .await | ||
| 1881 | .unwrap_or(false); | ||
| 1882 | |||
| 1883 | if is_ready { | ||
| 1884 | ready = true; | ||
| 1885 | break; | ||
| 1886 | } | ||
| 1887 | |||
| 1888 | if tokio::time::Instant::now() >= deadline { | ||
| 1889 | break; | ||
| 1890 | } | ||
| 1891 | |||
| 1892 | tokio::time::sleep(poll_interval).await; | ||
| 1893 | } | ||
| 1894 | |||
| 1895 | let count = if ready { | ||
| 1896 | ready_count.fetch_add(1, Ordering::Relaxed) + 1 | ||
| 1897 | } else { | ||
| 1898 | ready_count.load(Ordering::Relaxed) | ||
| 1899 | }; | ||
| 1900 | |||
| 1901 | // Update spinner message | ||
| 1902 | spinner_pb.set_message(format!( | ||
| 1903 | "waiting for servers to create bare git repo... ({count}/{total} - complete)" | ||
| 1904 | )); | ||
| 1905 | |||
| 1906 | // Finish per-server bar (deferred if detail not yet visible) | ||
| 1907 | let name = url | ||
| 1908 | .trim_start_matches("https://") | ||
| 1909 | .trim_start_matches("http://") | ||
| 1910 | .to_string(); | ||
| 1911 | if ready { | ||
| 1912 | let style = ProgressStyle::with_template(&format!( | ||
| 1913 | " {} {{msg}}", | ||
| 1914 | console::style("✔").for_stderr().green() | ||
| 1915 | )) | ||
| 1916 | .unwrap(); | ||
| 1917 | let msg = console::style(format!("{name} - ready")) | ||
| 1918 | .for_stderr() | ||
| 1919 | .green() | ||
| 1920 | .to_string(); | ||
| 1921 | finish_server_bar(&bar, style, msg, &reveal_state); | ||
| 1922 | } else { | ||
| 1923 | let style = ProgressStyle::with_template(&format!( | ||
| 1924 | " {} {{msg}}", | ||
| 1925 | console::style("✘").for_stderr().red() | ||
| 1926 | )) | ||
| 1927 | .unwrap(); | ||
| 1928 | let msg = console::style(format!("{name} - timeout")) | ||
| 1929 | .for_stderr() | ||
| 1930 | .red() | ||
| 1931 | .to_string(); | ||
| 1932 | finish_server_bar(&bar, style, msg, &reveal_state); | ||
| 1933 | } | ||
| 1934 | |||
| 1935 | ready | ||
| 1936 | } | ||
| 1937 | }) | 1977 | }) |
| 1938 | .collect(); | 1978 | .collect(); |
| 1939 | 1979 | ||
| 1940 | let results = join_all(futures).await; | 1980 | let results = join_all(futures).await; |
| 1941 | let final_ready = ready_count.load(Ordering::Relaxed); | 1981 | let final_ready = ready_count.load(Ordering::Relaxed); |
| 1942 | 1982 | ||
| 1943 | // Cancel the expand timer if it hasn't fired yet. | ||
| 1944 | timer_handle.abort(); | 1983 | timer_handle.abort(); |
| 1945 | 1984 | ||
| 1946 | // If detail view was revealed, clear the detail bars. | ||
| 1947 | if reveal_state.revealed.load(Ordering::Acquire) { | 1985 | if reveal_state.revealed.load(Ordering::Acquire) { |
| 1948 | let _ = detail_multi.clear(); | 1986 | let _ = detail_multi.clear(); |
| 1949 | } | 1987 | } |
| 1950 | 1988 | ||
| 1951 | let all_ready = results.iter().all(|&r| r); | 1989 | let all_ready = results.iter().all(|&r| r); |
| 1952 | if all_ready { | 1990 | finalize_spinner(all_ready, &spinner_pb, final_ready, total); |
| 1953 | // Success — erase the spinner line entirely, leave nothing behind. | ||
| 1954 | spinner_pb.finish_and_clear(); | ||
| 1955 | } else { | ||
| 1956 | // Partial timeout — leave a message so the user knows we proceeded. | ||
| 1957 | spinner_pb.set_style(ProgressStyle::with_template("{msg}").unwrap()); | ||
| 1958 | spinner_pb.finish_with_message(format!( | ||
| 1959 | "timed out waiting for servers to create bare git repo ({final_ready}/{total} - complete), proceeding anyway" | ||
| 1960 | )); | ||
| 1961 | } | ||
| 1962 | 1991 | ||
| 1963 | Ok(()) | 1992 | Ok(()) |
| 1964 | } | 1993 | } |