Skip to main content

socket_patch_cli/commands/
unlock.rs

1//! `socket-patch unlock` — inspect (and optionally release) the
2//! `<.socket>/apply.lock` advisory file lock used by mutating
3//! subcommands.
4//!
5//! Default behavior (no flags): probes the lock and prints
6//! `status: "free" | "held"`. Returns 0 when free, 1 when held —
7//! lets CI gating and monitoring tooling pattern-match the exit
8//! code without parsing JSON.
9//!
10//! With `--release`: when the lock is free, also deletes the lock
11//! file. The file is normally retained across runs (see
12//! `apply_lock` docs — the inode persists so subsequent acquires
13//! don't race on file creation), so `--release` exists for
14//! operators who want a true clean slate. Refused when the lock is
15//! held — that's the `--break-lock` flag's job on the mutating
16//! subcommands, and routing the two through different verbs makes
17//! the dangerous override explicit.
18
19use std::path::Path;
20use std::time::Duration;
21
22use clap::Args;
23use socket_patch_core::patch::apply_lock::{acquire, LockError};
24use socket_patch_core::utils::telemetry::{track_patch_unlock_failed, track_patch_unlocked};
25
26use crate::args::{apply_env_toggles, GlobalArgs};
27use crate::json_envelope::{Command, Envelope, EnvelopeError};
28
29#[derive(Args)]
30pub struct UnlockArgs {
31    #[command(flatten)]
32    pub common: GlobalArgs,
33
34    /// When the lock is free, also delete the lock file. Refused if
35    /// the lock is currently held — use `--break-lock` on the
36    /// mutating subcommand instead for that scenario.
37    #[arg(long = "release", env = "SOCKET_UNLOCK_RELEASE", default_value_t = false)]
38    pub release: bool,
39}
40
41pub async fn run(args: UnlockArgs) -> i32 {
42    apply_env_toggles(&args.common);
43
44    let socket_dir = args.common.cwd.join(".socket");
45    let lock_file = socket_dir.join("apply.lock");
46    let api_token = args.common.api_token.clone();
47    let org_slug = args.common.org.clone();
48
49    // No `.socket/` at all → treat as "free" (no one could be
50    // holding a lock that doesn't exist). Useful for fresh repos
51    // where the operator wants to confirm no stale state remains.
52    if !socket_dir.exists() {
53        // No lock to inspect → was_held=false, released matches whether
54        // the user asked for --release (no file existed to remove).
55        track_patch_unlocked(false, args.release, api_token.as_deref(), org_slug.as_deref()).await;
56        return emit_free(args.common.json, &lock_file, false, args.release);
57    }
58
59    match acquire(&socket_dir, Duration::ZERO) {
60        Ok(guard) => {
61            // We successfully claimed the lock — nobody else holds
62            // it. Release our handle before deleting the file so the
63            // delete races nothing.
64            drop(guard);
65
66            if args.release {
67                match std::fs::remove_file(&lock_file) {
68                    Ok(()) => {
69                        track_patch_unlocked(false, true, api_token.as_deref(), org_slug.as_deref())
70                            .await;
71                        emit_free(args.common.json, &lock_file, true, true)
72                    }
73                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
74                        // The file was never created (e.g. socket
75                        // dir existed but no run has acquired the
76                        // lock yet). Treat as success.
77                        track_patch_unlocked(false, true, api_token.as_deref(), org_slug.as_deref())
78                            .await;
79                        emit_free(args.common.json, &lock_file, false, true)
80                    }
81                    Err(e) => {
82                        let msg = format!(
83                            "failed to remove lock file at {}: {}",
84                            lock_file.display(),
85                            e
86                        );
87                        track_patch_unlock_failed(&msg, api_token.as_deref(), org_slug.as_deref())
88                            .await;
89                        emit_error(args.common.json, args.common.silent, "lock_io", &msg);
90                        1
91                    }
92                }
93            } else {
94                track_patch_unlocked(false, false, api_token.as_deref(), org_slug.as_deref()).await;
95                emit_free(args.common.json, &lock_file, false, false)
96            }
97        }
98        Err(LockError::Held) => {
99            track_patch_unlock_failed(
100                "lock held by another process",
101                api_token.as_deref(),
102                org_slug.as_deref(),
103            )
104            .await;
105            if args.common.json {
106                let mut env = Envelope::new(Command::Unlock);
107                env.mark_error(EnvelopeError::new(
108                    "lock_held",
109                    format!(
110                        "another socket-patch process is operating in {}",
111                        socket_dir.display()
112                    ),
113                ));
114                println!("{}", env.to_pretty_json());
115            } else if !args.common.silent {
116                eprintln!(
117                    "Lock is held: another socket-patch process is operating in {}.",
118                    socket_dir.display()
119                );
120                if args.release {
121                    eprintln!(
122                        "  Refusing to release a held lock. Re-run the failing mutating command with --break-lock if you're sure no holder exists."
123                    );
124                } else {
125                    eprintln!(
126                        "  Re-run the failing mutating command with --break-lock if you're sure no holder exists."
127                    );
128                }
129            }
130            1
131        }
132        Err(LockError::Io { path, source }) => {
133            let msg = format!(
134                "failed to open lock file at {}: {}",
135                path.display(),
136                source
137            );
138            track_patch_unlock_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await;
139            emit_error(args.common.json, args.common.silent, "lock_io", &msg);
140            1
141        }
142    }
143}
144
145/// Print the "free" success envelope and return exit code 0.
146/// `removed` is true when `--release` actually deleted the file
147/// (vs. the no-op case where the file didn't exist).
148fn emit_free(json: bool, lock_file: &Path, removed: bool, release: bool) -> i32 {
149    if json {
150        // Build the success body by hand rather than re-using the
151        // shared `Envelope` shape — the `events`/`summary` fields
152        // don't carry useful information here, and a flat
153        // `{status, lockFile, ...}` is friendlier to jq pipelines.
154        // We still tag `command: "unlock"` so generic consumers
155        // can route on subcommand identity.
156        let body = serde_json::json!({
157            "command": "unlock",
158            "status": "free",
159            "lockFile": lock_file.display().to_string(),
160            "released": removed,
161        });
162        println!("{}", serde_json::to_string_pretty(&body).unwrap());
163    } else if release && removed {
164        println!("Lock is free. Removed {}.", lock_file.display());
165    } else if release {
166        println!("Lock is free (no lock file to remove).");
167    } else {
168        println!("Lock is free.");
169    }
170    0
171}
172
173fn emit_error(json: bool, silent: bool, code: &str, message: &str) {
174    if json {
175        let mut env = Envelope::new(Command::Unlock);
176        env.mark_error(EnvelopeError::new(code, message));
177        println!("{}", env.to_pretty_json());
178    } else if !silent {
179        eprintln!("Error: {message}.");
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use socket_patch_core::patch::apply_lock::acquire as core_acquire;
187
188    /// Build a `UnlockArgs` rooted at a tempdir for the test.
189    fn args_in(cwd: &Path, release: bool) -> UnlockArgs {
190        UnlockArgs {
191            common: GlobalArgs {
192                cwd: cwd.to_path_buf(),
193                json: true, // exercise the JSON path in unit tests
194                silent: true,
195                ..GlobalArgs::default()
196            },
197            release,
198        }
199    }
200
201    /// No `.socket/` directory at all → report `free`, exit 0.
202    /// Mirrors what a fresh `git clone` looks like.
203    #[tokio::test]
204    async fn run_reports_free_when_socket_dir_missing() {
205        let dir = tempfile::tempdir().unwrap();
206        let code = run(args_in(dir.path(), false)).await;
207        assert_eq!(code, 0);
208    }
209
210    /// `.socket/` exists but no run has taken the lock yet — still
211    /// `free`. We exercise this by creating the directory ourselves.
212    #[tokio::test]
213    async fn run_reports_free_when_socket_dir_clean() {
214        let dir = tempfile::tempdir().unwrap();
215        std::fs::create_dir_all(dir.path().join(".socket")).unwrap();
216        let code = run(args_in(dir.path(), false)).await;
217        assert_eq!(code, 0);
218    }
219
220    /// Active holder (via core `acquire`) → `unlock` reports
221    /// `held`, exits 1, and the file remains on disk.
222    #[tokio::test]
223    async fn run_reports_held_when_lock_actively_held() {
224        let dir = tempfile::tempdir().unwrap();
225        let socket_dir = dir.path().join(".socket");
226        std::fs::create_dir_all(&socket_dir).unwrap();
227
228        // Hold the lock for the duration of this test. `_guard` is
229        // bound so its drop doesn't fire until function return.
230        let _guard = core_acquire(&socket_dir, Duration::ZERO).unwrap();
231
232        let code = run(args_in(dir.path(), false)).await;
233        assert_eq!(code, 1);
234        assert!(socket_dir.join("apply.lock").is_file());
235    }
236
237    /// `--release` against a free lock with a leftover file removes
238    /// the file.
239    #[tokio::test]
240    async fn run_deletes_lock_file_when_release_and_free() {
241        let dir = tempfile::tempdir().unwrap();
242        let socket_dir = dir.path().join(".socket");
243        std::fs::create_dir_all(&socket_dir).unwrap();
244        std::fs::write(socket_dir.join("apply.lock"), b"").unwrap();
245        assert!(socket_dir.join("apply.lock").is_file());
246
247        let code = run(args_in(dir.path(), true)).await;
248        assert_eq!(code, 0);
249        assert!(
250            !socket_dir.join("apply.lock").exists(),
251            "--release should have deleted the file"
252        );
253    }
254
255    /// `--release` against a HELD lock refuses (exit 1), file stays.
256    #[tokio::test]
257    async fn run_refuses_release_when_held() {
258        let dir = tempfile::tempdir().unwrap();
259        let socket_dir = dir.path().join(".socket");
260        std::fs::create_dir_all(&socket_dir).unwrap();
261        let _guard = core_acquire(&socket_dir, Duration::ZERO).unwrap();
262
263        let code = run(args_in(dir.path(), true)).await;
264        assert_eq!(code, 1);
265        assert!(
266            socket_dir.join("apply.lock").is_file(),
267            "lock file should still exist — --release must refuse when held"
268        );
269    }
270}