socket_patch_cli/commands/
unlock.rs1use 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 #[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 if !socket_dir.exists() {
53 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 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 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
145fn emit_free(json: bool, lock_file: &Path, removed: bool, release: bool) -> i32 {
149 if json {
150 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 fn args_in(cwd: &Path, release: bool) -> UnlockArgs {
190 UnlockArgs {
191 common: GlobalArgs {
192 cwd: cwd.to_path_buf(),
193 json: true, silent: true,
195 ..GlobalArgs::default()
196 },
197 release,
198 }
199 }
200
201 #[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 #[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 #[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 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 #[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 #[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}