mi6_cli/commands/
upgrade.rs

1//! Self-upgrade command for mi6.
2//!
3//! Detects installation method and uses the appropriate upgrade strategy:
4//! - Homebrew: `brew upgrade mi6`
5//! - Cargo (registry): `cargo install mi6`
6//! - Cargo (path): `cargo install --path <original-path>`
7//! - Standalone: Uses `self_update` crate to download from GitHub releases
8//!
9//! Supports switching between installation methods with automatic rollback on failure.
10
11use std::path::PathBuf;
12use std::process::Command;
13
14use anyhow::{Context, Result, bail};
15
16use crate::args::UpgradeMethod;
17use crate::display::{bold_green, bold_white, confirm_stdout};
18
19use super::update_check::check_for_update_with_method;
20
21/// How mi6 was installed
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum InstallMethod {
24    /// Installed via Homebrew
25    Homebrew,
26    /// Installed via `cargo install mi6` (from crates.io registry)
27    CargoRegistry,
28    /// Installed via `cargo install --path <path>` (from local source)
29    CargoPath(PathBuf),
30    /// Standalone binary (e.g., downloaded from GitHub releases or install.sh)
31    Standalone,
32}
33
34impl InstallMethod {
35    /// Detect the installation method based on the executable path and cargo metadata
36    pub fn detect() -> Result<Self> {
37        let exe_path = std::env::current_exe().context("failed to get current executable path")?;
38        let path_str = exe_path.to_string_lossy();
39
40        if path_str.contains("/Cellar/") || path_str.contains("/homebrew/") {
41            return Ok(Self::Homebrew);
42        }
43
44        if path_str.contains("/.cargo/bin/") {
45            // Use `cargo install --list` to determine if installed from registry or path
46            if let Some(method) = Self::detect_cargo_source()? {
47                return Ok(method);
48            }
49            // Fallback to registry if we can't determine
50            return Ok(Self::CargoRegistry);
51        }
52
53        Ok(Self::Standalone)
54    }
55
56    /// Use `cargo install --list` to determine the install source
57    ///
58    /// Output format:
59    /// - Registry install: `mi6 v0.2.0:`
60    /// - Path install: `mi6 v0.2.0 (/path/to/crate):`
61    fn detect_cargo_source() -> Result<Option<Self>> {
62        let output = Command::new("cargo")
63            .args(["install", "--list"])
64            .output()
65            .context("failed to run cargo install --list")?;
66
67        if !output.status.success() {
68            return Ok(None);
69        }
70
71        let stdout = String::from_utf8_lossy(&output.stdout);
72
73        // Find the line starting with "mi6 v"
74        for line in stdout.lines() {
75            if !line.starts_with("mi6 v") {
76                continue;
77            }
78
79            // Check if there's a path in parentheses: "mi6 v0.2.0 (/path/to/crate):"
80            if let Some(start) = line.find(" (")
81                && let Some(end) = line.rfind("):")
82            {
83                let path_str = &line[start + 2..end];
84                return Ok(Some(Self::CargoPath(PathBuf::from(path_str))));
85            }
86
87            // No path means registry install: "mi6 v0.2.0:"
88            return Ok(Some(Self::CargoRegistry));
89        }
90
91        Ok(None)
92    }
93
94    /// Get a human-readable name for the install method
95    pub fn name(&self) -> &'static str {
96        match self {
97            Self::Homebrew => "Homebrew",
98            Self::CargoRegistry => "Cargo (crates.io)",
99            Self::CargoPath(_) => "Cargo (source)",
100            Self::Standalone => "Standalone",
101        }
102    }
103
104    /// Convert to UpgradeMethod (for comparison purposes)
105    pub fn to_upgrade_method(&self) -> UpgradeMethod {
106        match self {
107            Self::Homebrew => UpgradeMethod::Brew,
108            Self::CargoRegistry => UpgradeMethod::CargoCrates,
109            Self::CargoPath(_) => UpgradeMethod::CargoSource,
110            Self::Standalone => UpgradeMethod::Github,
111        }
112    }
113
114    /// Create from UpgradeMethod with optional path for CargoSource
115    pub fn from_upgrade_method(method: UpgradeMethod, path: Option<PathBuf>) -> Result<Self> {
116        match method {
117            UpgradeMethod::Brew => Ok(Self::Homebrew),
118            UpgradeMethod::CargoCrates => Ok(Self::CargoRegistry),
119            UpgradeMethod::Github => Ok(Self::Standalone),
120            UpgradeMethod::CargoSource => {
121                let source_path = path.ok_or_else(|| {
122                    anyhow::anyhow!(
123                        "--source-path is required when using --method cargo_source\n\
124                         Example: mi6 upgrade --method cargo_source --source-path /path/to/mi6"
125                    )
126                })?;
127                Ok(Self::CargoPath(source_path))
128            }
129        }
130    }
131
132    /// Check if prerequisites for this installation method are met
133    pub fn check_prerequisites(&self) -> Result<()> {
134        match self {
135            Self::Homebrew => {
136                let output = Command::new("brew")
137                    .arg("--version")
138                    .output()
139                    .context("brew is not installed or not in PATH")?;
140                if !output.status.success() {
141                    bail!("brew is not working properly");
142                }
143                Ok(())
144            }
145            Self::CargoRegistry | Self::CargoPath(_) => {
146                let output = Command::new("cargo")
147                    .arg("--version")
148                    .output()
149                    .context("cargo is not installed or not in PATH")?;
150                if !output.status.success() {
151                    bail!("cargo is not working properly");
152                }
153                // For CargoPath, check that the source directory exists and is a valid Rust project
154                if let Self::CargoPath(path) = self {
155                    if !path.exists() {
156                        bail!(
157                            "Source directory does not exist: {}\n\
158                             Please provide a valid path to the mi6 source.",
159                            path.display()
160                        );
161                    }
162                    let cargo_toml = path.join("Cargo.toml");
163                    if !cargo_toml.exists() {
164                        bail!(
165                            "Not a valid Rust project (no Cargo.toml): {}\n\
166                             Please provide a path to the mi6 source directory.",
167                            path.display()
168                        );
169                    }
170                }
171                Ok(())
172            }
173            Self::Standalone => {
174                // No prerequisites for standalone - just needs network access
175                Ok(())
176            }
177        }
178    }
179}
180
181/// Options for the upgrade command
182pub struct UpgradeOptions {
183    /// Target version (only for Cargo registry installs)
184    pub version: Option<String>,
185    /// Skip confirmation prompt
186    pub yes: bool,
187    /// Check for updates without installing
188    pub dry_run: bool,
189    /// Override installation method
190    pub method: Option<UpgradeMethod>,
191    /// Source path for cargo_source method
192    pub source_path: Option<PathBuf>,
193}
194
195/// Run the upgrade command
196pub fn run_upgrade(options: UpgradeOptions) -> Result<()> {
197    let current_version = env!("CARGO_PKG_VERSION");
198    let current_method = InstallMethod::detect()?;
199
200    println!("mi6 v{}", current_version);
201    println!();
202    println!(
203        "{} {} install",
204        bold_green("Detected:"),
205        current_method.name()
206    );
207    println!();
208
209    // Determine target method (either specified or current)
210    let target_method = if let Some(method) = options.method {
211        // If switching to cargo_source and no path provided, try to use current path
212        let path = options.source_path.clone().or_else(|| {
213            if let InstallMethod::CargoPath(p) = &current_method {
214                Some(p.clone())
215            } else {
216                None
217            }
218        });
219        // Canonicalize the path to avoid issues with relative paths
220        let path = path.map(|p| p.canonicalize().unwrap_or(p));
221        InstallMethod::from_upgrade_method(method, path)?
222    } else {
223        current_method.clone()
224    };
225
226    let is_switching = current_method.to_upgrade_method() != target_method.to_upgrade_method();
227
228    // Warn if --version is used with non-registry cargo install methods
229    if options.version.is_some() && !matches!(target_method, InstallMethod::CargoRegistry) {
230        println!(
231            "Warning: --version is only supported for Cargo registry installs (ignored for {})",
232            target_method.name()
233        );
234        println!();
235    }
236
237    // Handle dry-run mode
238    if options.dry_run {
239        if is_switching {
240            println!(
241                "Would switch from {} to {}",
242                current_method.name(),
243                target_method.name()
244            );
245            println!();
246
247            // Check prerequisites even in dry-run to warn early
248            print!("Checking prerequisites for {}... ", target_method.name());
249            match target_method.check_prerequisites() {
250                Ok(()) => println!("OK"),
251                Err(e) => {
252                    println!("FAILED");
253                    println!();
254                    println!("Warning: Switch would fail: {}", e);
255                    println!();
256                }
257            }
258        }
259        return check_for_updates(current_version, &target_method);
260    }
261
262    // If switching methods, go through the switch flow
263    if is_switching {
264        return switch_method(&current_method, &target_method, &options);
265    }
266
267    // Check if already on latest version (skip if user specified a specific version)
268    if options.version.is_none() {
269        print!("Checking for updates... ");
270        match check_for_update_with_method(current_version, &target_method) {
271            Ok(None) => {
272                println!("already on latest version (v{}).", current_version);
273                return Ok(());
274            }
275            Ok(Some(update_info)) => {
276                println!(
277                    "v{} available (current: v{}).",
278                    update_info.latest_version, current_version
279                );
280                println!();
281            }
282            Err(e) => {
283                // Log warning but continue with upgrade anyway
284                println!("check failed: {}", e);
285                println!();
286                println!("Continuing with upgrade...");
287                println!();
288            }
289        }
290    }
291
292    // Same method upgrade
293    let completed = match &target_method {
294        InstallMethod::Homebrew => upgrade_homebrew(options.yes)?,
295        InstallMethod::CargoRegistry => {
296            upgrade_cargo_registry(options.version.as_deref(), options.yes)?
297        }
298        InstallMethod::CargoPath(path) => upgrade_cargo_path(path, options.yes)?,
299        InstallMethod::Standalone => upgrade_standalone(current_version, options.yes)?,
300    };
301
302    if completed {
303        println!();
304        println!("Done! Run `mi6 --version` to verify.");
305    }
306
307    Ok(())
308}
309
310/// Switch from one installation method to another.
311///
312/// This function ensures the mi6 binary is always available during the switch
313/// by installing the new method BEFORE uninstalling the old one. This is critical
314/// for live sessions that have hooks calling `mi6 ingest event`.
315///
316/// For cargo-to-cargo switches (same binary location), the install atomically
317/// overwrites so no separate uninstall is needed.
318fn switch_method(from: &InstallMethod, to: &InstallMethod, options: &UpgradeOptions) -> Result<()> {
319    println!(
320        "Switching installation method: {} -> {}",
321        from.name(),
322        to.name()
323    );
324    println!();
325
326    // Step 1: Check prerequisites for the new method BEFORE any changes
327    println!("Checking prerequisites for {}...", to.name());
328    to.check_prerequisites()
329        .with_context(|| format!("Cannot switch to {}: prerequisites not met", to.name()))?;
330    println!("Prerequisites OK.");
331    println!();
332
333    // Determine if this is a cargo-to-cargo switch (same binary location, atomic overwrite)
334    let is_cargo_to_cargo = matches!(
335        (from, to),
336        (
337            InstallMethod::CargoRegistry | InstallMethod::CargoPath(_),
338            InstallMethod::CargoRegistry | InstallMethod::CargoPath(_)
339        )
340    );
341
342    // Step 2: Confirm with user
343    if !options.yes {
344        println!("This will:");
345        if is_cargo_to_cargo {
346            println!("  Install mi6 via {} (overwrites existing)", to.name());
347        } else {
348            println!("  1. Install mi6 via {}", to.name());
349            println!("  2. Uninstall mi6 via {} (binary only)", from.name());
350        }
351        println!();
352        println!("Note: Your mi6 data (database, config, themes) will be preserved.");
353        println!();
354        if !confirm_stdout("Proceed with method switch?")? {
355            println!("Aborted.");
356            return Ok(());
357        }
358        println!();
359    }
360
361    if is_cargo_to_cargo {
362        // Cargo-to-cargo: atomic overwrite, use --force to ensure replacement
363        println!("Installing via {} (atomic overwrite)...", to.name());
364        install_binary_forced(to, options.version.as_deref())
365            .with_context(|| format!("Failed to install via {}", to.name()))?;
366    } else {
367        // Different locations: install new first, then uninstall old
368        // This ensures mi6 is always available for live sessions
369
370        // Step 3: Install new method first (old binary still available)
371        println!("Installing via {}...", to.name());
372        if let Err(install_err) = install_binary(to, options.version.as_deref()) {
373            // Install failed, but old binary is still there - no rollback needed
374            bail!(
375                "Failed to install via {}: {}\n\n\
376                 Your current installation ({}) is unchanged.",
377                to.name(),
378                install_err,
379                from.name()
380            );
381        }
382        println!("Install complete.");
383        println!();
384
385        // Step 4: Now safe to uninstall old method (new binary is ready)
386        println!("Removing old installation ({})...", from.name());
387        if let Err(uninstall_err) = uninstall_binary(from) {
388            // New binary is installed, but old one couldn't be removed
389            // This is not critical - warn user but don't fail
390            eprintln!();
391            eprintln!(
392                "WARNING: Could not remove old {} installation: {}",
393                from.name(),
394                uninstall_err
395            );
396            eprintln!();
397            eprintln!("You may have two mi6 binaries installed.");
398            eprintln!("Run `which mi6` to verify you're using the correct binary.");
399            eprintln!();
400        } else {
401            println!("Old installation removed.");
402            println!();
403        }
404    }
405
406    println!(
407        "Done! Successfully switched from {} to {}.",
408        from.name(),
409        to.name()
410    );
411    println!("Run `mi6 --version` to verify.");
412
413    Ok(())
414}
415
416/// Uninstall the mi6 binary using the specified method (binary only, preserves data).
417fn uninstall_binary(method: &InstallMethod) -> Result<()> {
418    match method {
419        InstallMethod::Homebrew => {
420            let status = Command::new("brew")
421                .args(["uninstall", "mi6"])
422                .status()
423                .context("failed to run brew uninstall")?;
424            if !status.success() {
425                bail!("brew uninstall failed with exit code: {:?}", status.code());
426            }
427        }
428        InstallMethod::CargoRegistry | InstallMethod::CargoPath(_) => {
429            let status = Command::new("cargo")
430                .args(["uninstall", "mi6"])
431                .status()
432                .context("failed to run cargo uninstall")?;
433            if !status.success() {
434                bail!("cargo uninstall failed with exit code: {:?}", status.code());
435            }
436        }
437        InstallMethod::Standalone => {
438            let exe_path =
439                std::env::current_exe().context("failed to get current executable path")?;
440            std::fs::remove_file(&exe_path)
441                .with_context(|| format!("failed to delete {}", exe_path.display()))?;
442        }
443    }
444    Ok(())
445}
446
447/// Install the mi6 binary using the specified method.
448fn install_binary(method: &InstallMethod, version: Option<&str>) -> Result<()> {
449    install_binary_impl(method, version, false)
450}
451
452/// Install the mi6 binary with --force flag (for cargo-to-cargo switches).
453fn install_binary_forced(method: &InstallMethod, version: Option<&str>) -> Result<()> {
454    install_binary_impl(method, version, true)
455}
456
457/// Internal implementation of binary installation.
458fn install_binary_impl(method: &InstallMethod, version: Option<&str>, force: bool) -> Result<()> {
459    match method {
460        InstallMethod::Homebrew => {
461            let status = Command::new("brew")
462                .args(["install", "mi6"])
463                .status()
464                .context("failed to run brew install")?;
465            if !status.success() {
466                bail!("brew install failed with exit code: {:?}", status.code());
467            }
468        }
469        InstallMethod::CargoRegistry => {
470            let mut args = vec!["install", "mi6"];
471            if force {
472                args.push("--force");
473            }
474            if let Some(v) = version {
475                args.extend(["--version", v]);
476            }
477            let status = Command::new("cargo")
478                .args(&args)
479                .status()
480                .context("failed to run cargo install")?;
481            if !status.success() {
482                bail!("cargo install failed with exit code: {:?}", status.code());
483            }
484        }
485        InstallMethod::CargoPath(path) => {
486            let path_str = path.to_string_lossy();
487            let mut args = vec!["install", "--path", path_str.as_ref()];
488            if force {
489                args.push("--force");
490            }
491            let status = Command::new("cargo")
492                .args(&args)
493                .status()
494                .context("failed to run cargo install --path")?;
495            if !status.success() {
496                bail!(
497                    "cargo install --path failed with exit code: {:?}",
498                    status.code()
499                );
500            }
501        }
502        InstallMethod::Standalone => {
503            // When switching TO standalone, we must install to a DIFFERENT location
504            // than the current binary. By default, self_update replaces current_exe(),
505            // which would be the brew/cargo location - then uninstall would delete it!
506            //
507            // Install to /usr/local/bin (standard location for standalone binaries)
508            let install_dir = PathBuf::from("/usr/local/bin");
509
510            // Ensure the directory exists and is writable
511            if !install_dir.exists() {
512                std::fs::create_dir_all(&install_dir).with_context(|| {
513                    format!(
514                        "Cannot create install directory: {}\n\
515                         You may need to run with sudo or choose a different installation method.",
516                        install_dir.display()
517                    )
518                })?;
519            }
520
521            // Use self_update to download from GitHub releases
522            // Use "0.0.0" as current version to force download even if versions match.
523            let status = self_update::backends::github::Update::configure()
524                .repo_owner("paradigmxyz")
525                .repo_name("mi6")
526                .bin_name("mi6")
527                .bin_install_path(&install_dir)
528                .current_version("0.0.0") // Force download
529                .show_download_progress(true)
530                .build()
531                .context("failed to configure self-updater")?
532                .update()
533                .context("failed to download from GitHub releases")?;
534
535            // Verify the download actually happened
536            if !status.updated() {
537                bail!(
538                    "GitHub download did not produce a binary. \
539                     This may indicate a problem with the release assets."
540                );
541            }
542
543            // Remind user about PATH if /usr/local/bin might not be in it
544            let installed_path = install_dir.join("mi6");
545            println!();
546            println!("Installed to: {}", installed_path.display());
547            println!("Ensure /usr/local/bin is in your PATH.");
548        }
549    }
550    Ok(())
551}
552
553/// Check for available updates without installing (dry-run mode)
554fn check_for_updates(current_version: &str, method: &InstallMethod) -> Result<()> {
555    match method {
556        InstallMethod::Standalone => {
557            println!("Checking GitHub for updates...");
558            println!();
559
560            let update = self_update::backends::github::Update::configure()
561                .repo_owner("paradigmxyz")
562                .repo_name("mi6")
563                .bin_name("mi6")
564                .current_version(current_version)
565                .build()
566                .context("failed to configure self-updater")?;
567
568            let latest = update
569                .get_latest_release()
570                .context("failed to check for updates")?;
571
572            let latest_version = latest.version.trim_start_matches('v');
573
574            if latest_version == current_version {
575                println!("Already at the latest version (v{}).", current_version);
576            } else {
577                println!(
578                    "Update available: v{} -> v{}",
579                    current_version, latest_version
580                );
581                println!();
582                println!("Run `mi6 upgrade` to install.");
583            }
584        }
585        InstallMethod::Homebrew => {
586            println!("To check for Homebrew updates, run:");
587            println!("  brew outdated mi6");
588        }
589        InstallMethod::CargoRegistry => {
590            println!("To check for Cargo updates, run:");
591            println!("  cargo search mi6");
592        }
593        InstallMethod::CargoPath(path) => {
594            println!("Installed from source at: {}", path.display());
595            println!();
596            println!("To check for updates, pull latest changes and reinstall:");
597            println!("  mi6 upgrade");
598        }
599    }
600
601    Ok(())
602}
603
604fn upgrade_homebrew(skip_confirm: bool) -> Result<bool> {
605    if !skip_confirm {
606        println!("This will run: brew upgrade mi6");
607        if !confirm_stdout("Proceed?")? {
608            println!("Aborted.");
609            return Ok(false);
610        }
611        println!();
612    }
613
614    println!("Running: brew upgrade mi6");
615    println!();
616
617    let status = Command::new("brew")
618        .args(["upgrade", "mi6"])
619        .status()
620        .context("failed to run brew upgrade")?;
621
622    if !status.success() {
623        // Homebrew returns non-zero if package is already at latest version
624        println!();
625        println!("Note: brew upgrade exited with non-zero status.");
626        println!("This usually means mi6 is already at the latest version.");
627        println!("If not, try: brew reinstall mi6");
628    }
629
630    Ok(true)
631}
632
633fn upgrade_cargo_registry(version: Option<&str>, skip_confirm: bool) -> Result<bool> {
634    let mut args = vec!["install", "mi6"];
635    if let Some(v) = version {
636        args.extend(["--version", v]);
637    }
638
639    if !skip_confirm {
640        println!("This will run: cargo {}", args.join(" "));
641        if !confirm_stdout("Proceed?")? {
642            println!("Aborted.");
643            return Ok(false);
644        }
645        println!();
646    }
647
648    println!("Running: cargo {}", args.join(" "));
649    println!();
650
651    let status = Command::new("cargo")
652        .args(&args)
653        .status()
654        .context("failed to run cargo install")?;
655
656    if !status.success() {
657        anyhow::bail!("cargo install failed with exit code: {:?}", status.code());
658    }
659
660    Ok(true)
661}
662
663fn upgrade_cargo_path(path: &std::path::Path, skip_confirm: bool) -> Result<bool> {
664    // Verify the path still exists
665    if !path.exists() {
666        anyhow::bail!(
667            "Source directory no longer exists: {}\n\
668             You may need to reinstall mi6 or update the source location.",
669            path.display()
670        );
671    }
672
673    // Find the workspace root (look for Cargo.toml with [workspace])
674    let workspace_root = find_workspace_root(path)?;
675
676    // Determine the git pull target - if we're in a worktree, pull from main worktree instead
677    let pull_target = workspace_root.as_ref().and_then(|root| {
678        let git_dir = root.join(".git");
679        if git_dir.exists() {
680            Some(find_main_worktree(root).unwrap_or_else(|| root.clone()))
681        } else {
682            None
683        }
684    });
685
686    let path_str = path.to_string_lossy();
687    let args = vec!["install", "--path", path_str.as_ref()];
688
689    if !skip_confirm {
690        println!("{}", bold_green("Upgrade process:"));
691        if let Some(ref target) = pull_target {
692            println!(
693                "{} Pull latest changes ({})",
694                bold_green("1."),
695                bold_white(&format!("git pull -C {}", target.display()))
696            );
697            println!(
698                "{} Run cargo install ({})",
699                bold_green("2."),
700                bold_white(&format!("cargo {}", args.join(" ")))
701            );
702        } else {
703            println!(
704                "Run cargo install ({})",
705                bold_white(&format!("cargo {}", args.join(" ")))
706            );
707        }
708        println!();
709        if !confirm_stdout("Proceed?")? {
710            println!("Aborted.");
711            return Ok(false);
712        }
713        println!();
714    }
715
716    // Pull latest changes if we have a git directory
717    if let Some(ref target) = pull_target {
718        println!("Pulling latest changes...");
719        let target_str = target.to_string_lossy();
720
721        let status = Command::new("git")
722            .args(["-C", target_str.as_ref(), "pull"])
723            .status()
724            .context("failed to run git pull")?;
725
726        if !status.success() {
727            anyhow::bail!("git pull failed. Please resolve any conflicts or issues and try again.");
728        }
729        println!();
730    }
731
732    println!("Running: cargo {}", args.join(" "));
733    println!();
734
735    let status = Command::new("cargo")
736        .args(&args)
737        .status()
738        .context("failed to run cargo install --path")?;
739
740    if !status.success() {
741        anyhow::bail!("cargo install failed with exit code: {:?}", status.code());
742    }
743
744    Ok(true)
745}
746
747/// Find the workspace root by looking for Cargo.toml with [workspace] section
748fn find_workspace_root(crate_path: &std::path::Path) -> Result<Option<PathBuf>> {
749    let mut current = crate_path.to_path_buf();
750
751    // Walk up the directory tree looking for workspace Cargo.toml
752    for _ in 0..10 {
753        let cargo_toml = current.join("Cargo.toml");
754        if cargo_toml.exists() {
755            let content = std::fs::read_to_string(&cargo_toml)?;
756            if content.contains("[workspace]") {
757                return Ok(Some(current));
758            }
759        }
760
761        if !current.pop() {
762            break;
763        }
764    }
765
766    Ok(None)
767}
768
769/// Find the main worktree directory from any git directory (including worktrees).
770///
771/// If the given path is within a git worktree, this returns the main worktree
772/// (the original repo directory). If it's already in the main worktree, returns None.
773fn find_main_worktree(git_path: &std::path::Path) -> Option<PathBuf> {
774    let path_str = git_path.to_string_lossy();
775
776    // Get the common git directory (shared across all worktrees)
777    let output = Command::new("git")
778        .args([
779            "-C",
780            &path_str,
781            "rev-parse",
782            "--path-format=absolute",
783            "--git-common-dir",
784        ])
785        .output()
786        .ok()?;
787
788    if !output.status.success() {
789        return None;
790    }
791
792    let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
793    let common_path = PathBuf::from(&common_dir);
794
795    // The main worktree is the parent of the common .git directory
796    // (e.g., /path/to/repo/.git -> /path/to/repo)
797    let main_worktree = common_path.parent()?.to_path_buf();
798
799    // Check if we're already in the main worktree by comparing to current path
800    // Get the git dir for the given path
801    let git_dir_output = Command::new("git")
802        .args([
803            "-C",
804            &path_str,
805            "rev-parse",
806            "--path-format=absolute",
807            "--git-dir",
808        ])
809        .output()
810        .ok()?;
811
812    if !git_dir_output.status.success() {
813        return None;
814    }
815
816    let git_dir = String::from_utf8_lossy(&git_dir_output.stdout)
817        .trim()
818        .to_string();
819
820    // If git-dir equals git-common-dir, we're in the main worktree
821    if git_dir == common_dir {
822        return None;
823    }
824
825    Some(main_worktree)
826}
827
828fn upgrade_standalone(current_version: &str, skip_confirm: bool) -> Result<bool> {
829    println!("Checking GitHub for updates...");
830    println!();
831
832    let update = self_update::backends::github::Update::configure()
833        .repo_owner("paradigmxyz")
834        .repo_name("mi6")
835        .bin_name("mi6")
836        .current_version(current_version)
837        .build()
838        .context("failed to configure self-updater")?;
839
840    // Check for update first
841    let latest = update
842        .get_latest_release()
843        .context("failed to check for updates")?;
844
845    let latest_version = latest.version.trim_start_matches('v');
846
847    if latest_version == current_version {
848        println!("Already at the latest version (v{}).", current_version);
849        return Ok(true);
850    }
851
852    println!(
853        "New version available: v{} -> v{}",
854        current_version, latest_version
855    );
856
857    if !skip_confirm && !confirm_stdout("Proceed with update?")? {
858        println!("Aborted.");
859        return Ok(false);
860    }
861    println!();
862
863    println!("Downloading and installing v{}...", latest_version);
864
865    // Create a new update with show_progress for the actual download
866    let status = self_update::backends::github::Update::configure()
867        .repo_owner("paradigmxyz")
868        .repo_name("mi6")
869        .bin_name("mi6")
870        .current_version(current_version)
871        .show_download_progress(true)
872        .build()
873        .context("failed to configure self-updater")?
874        .update()
875        .context("failed to perform update")?;
876
877    println!();
878    println!("Updated to v{}!", status.version());
879
880    Ok(true)
881}