Skip to main content

ralph/cli/queue/
unlock.rs

1//! Queue unlock subcommand.
2//!
3//! Responsibilities:
4//! - Remove the queue lock directory with safety checks.
5//! - Detect active lock holders and require explicit override.
6//! - Provide dry-run preview of lock state.
7//!
8//! Not handled here:
9//! - Lock acquisition (see `crate::lock`).
10//! - Queue file validation.
11//!
12//! Invariants/assumptions:
13//! - Confirmation is required in interactive contexts unless --yes is set.
14//! - Active process detection is conservative: indeterminate status is treated as running.
15
16use anyhow::{Context, Result, bail};
17use clap::Args;
18use std::io::{self, IsTerminal, Write};
19use std::path::Path;
20
21use crate::config::Resolved;
22use crate::lock::{self, PidLiveness};
23
24/// Arguments for `ralph queue unlock`.
25#[derive(Args)]
26#[command(after_long_help = "Safely remove the queue lock directory.\n\n\
27Safety:\n  - Checks if the lock holder process is still running\n  - Blocks if process is active (override with --force)\n  - Requires confirmation in interactive mode (bypass with --yes)\n\n\
28Examples:\n  ralph queue unlock --dry-run\n  ralph queue unlock --yes\n  ralph queue unlock --force --yes\n  ralph queue unlock --force  # Still requires confirmation")]
29pub struct QueueUnlockArgs {
30    /// Override active process check and remove lock anyway.
31    #[arg(long)]
32    pub force: bool,
33
34    /// Skip confirmation prompt (use with caution).
35    #[arg(long)]
36    pub yes: bool,
37
38    /// Show what would happen without removing the lock.
39    #[arg(long)]
40    pub dry_run: bool,
41}
42
43pub(crate) fn handle(resolved: &Resolved, args: QueueUnlockArgs) -> Result<()> {
44    let lock_dir = lock::queue_lock_dir(&resolved.repo_root);
45
46    if !lock_dir.exists() {
47        log::info!("Queue is not locked.");
48        return Ok(());
49    }
50
51    // Read lock owner metadata
52    let owner = lock::read_lock_owner(&lock_dir)?;
53    let owner_info = format_owner_info(&owner);
54
55    // Check if lock holder process is still active
56    let liveness = owner.as_ref().map(|o| lock::pid_liveness(o.pid));
57    let is_active = liveness
58        .as_ref()
59        .is_some_and(|l| l.is_running_or_indeterminate());
60
61    // Determine action based on state and flags
62    if args.dry_run {
63        handle_dry_run(&lock_dir, &owner_info, is_active, liveness.as_ref())?;
64        return Ok(());
65    }
66
67    // Safety check: block if process is active unless --force is set
68    if is_active && !args.force {
69        let pid_str = owner
70            .as_ref()
71            .map(|o| o.pid.to_string())
72            .unwrap_or_else(|| "unknown".to_string());
73        bail!(
74            "Refusing to unlock: lock holder process (PID {}) appears to be still running.\n\
75             Lock holder: {}\n\n\
76             Use --force to override this check, or verify the process has exited.\n\
77             Example: ralph queue unlock --force --yes",
78            pid_str,
79            owner_info
80        );
81    }
82
83    // Require confirmation in interactive contexts
84    if !args.yes
85        && is_terminal_context()
86        && !confirm_unlock(&lock_dir, &owner_info, is_active, args.force)?
87    {
88        log::info!("Unlock cancelled.");
89        return Ok(());
90    }
91
92    // Warn when force-removing an active lock
93    if is_active && args.force {
94        log::warn!(
95            "Force-removing lock for active process (PID: {}). Queue corruption may occur if the process is still writing.",
96            owner.as_ref().map(|o| o.pid).unwrap_or(0)
97        );
98    }
99
100    // Perform the unlock
101    std::fs::remove_dir_all(&lock_dir)
102        .with_context(|| format!("remove lock dir {}", lock_dir.display()))?;
103
104    log::info!("Queue unlocked (removed {}).", lock_dir.display());
105    Ok(())
106}
107
108fn format_owner_info(owner: &Option<lock::LockOwner>) -> String {
109    match owner {
110        Some(o) => format!(
111            "PID={}, label={}, started={}, command={}",
112            o.pid, o.label, o.started_at, o.command
113        ),
114        None => "(owner metadata missing)".to_string(),
115    }
116}
117
118fn handle_dry_run(
119    lock_dir: &Path,
120    owner_info: &str,
121    is_active: bool,
122    liveness: Option<&PidLiveness>,
123) -> Result<()> {
124    println!("Lock directory: {}", lock_dir.display());
125    println!("Lock holder: {}", owner_info);
126
127    match liveness {
128        Some(PidLiveness::Running) => {
129            println!("Process status: RUNNING (unlock would be blocked without --force)");
130        }
131        Some(PidLiveness::NotRunning) => {
132            println!("Process status: NOT RUNNING (safe to unlock)");
133        }
134        Some(PidLiveness::Indeterminate) => {
135            println!("Process status: INDETERMINATE (unlock would be blocked without --force)");
136        }
137        None => {
138            println!("Process status: UNKNOWN (no owner metadata)");
139        }
140    }
141
142    if is_active {
143        println!(
144            "Would remove: {} (blocked without --force)",
145            lock_dir.display()
146        );
147    } else {
148        println!("Would remove: {} (safe to unlock)", lock_dir.display());
149    }
150
151    println!("Dry run: no changes made.");
152    Ok(())
153}
154
155fn is_terminal_context() -> bool {
156    io::stdin().is_terminal() && io::stdout().is_terminal()
157}
158
159fn confirm_unlock(lock_dir: &Path, owner_info: &str, is_active: bool, force: bool) -> Result<bool> {
160    println!("About to remove lock directory: {}", lock_dir.display());
161    println!("Lock holder: {}", owner_info);
162    if is_active {
163        if force {
164            println!("WARNING: Force-removing lock for ACTIVE process!");
165            println!("         Data corruption may occur if the process is still writing.");
166        } else {
167            println!("WARNING: Lock holder process may still be active!");
168        }
169    }
170    print!("Proceed with unlock? [y/N]: ");
171    io::stdout().flush().context("flush confirmation prompt")?;
172
173    let mut response = String::new();
174    io::stdin()
175        .read_line(&mut response)
176        .context("read confirmation input")?;
177
178    Ok(matches!(
179        response.trim().to_lowercase().as_str(),
180        "y" | "yes"
181    ))
182}