git_stk/stack/
snapshot.rs1use std::sync::atomic::{AtomicBool, Ordering};
7
8use anyhow::{Context, Result};
9use serde_json::{Value, json};
10
11use super::{base_of, branch_and_descendants, is_floor, parent_of, stack_root};
12use crate::git;
13use crate::style;
14
15const SNAPSHOT_FILE: &str = "stk-undo";
16
17static TAKEN: AtomicBool = AtomicBool::new(false);
20
21pub fn take(label: &str) {
25 if TAKEN.swap(true, Ordering::Relaxed) {
26 return;
27 }
28 if let Err(error) = capture(label) {
29 let _ = error;
31 }
32}
33
34fn capture(label: &str) -> Result<()> {
35 let head = git::current_branch()?;
36 let root = stack_root(&head)?;
37
38 let branches: Vec<Value> = branch_and_descendants(&root)?
39 .into_iter()
40 .map(|branch| {
41 json!({
42 "name": branch,
43 "sha": git::branch_sha(&branch),
44 "parent": parent_of(&branch).ok().flatten(),
45 "base": base_of(&branch).ok().flatten(),
46 "floor": is_floor(&branch).unwrap_or(false).then_some("true"),
49 })
50 })
51 .collect();
52
53 let snapshot = json!({
54 "label": label,
55 "head": head,
56 "branches": branches,
57 });
58 let path = git::git_path(SNAPSHOT_FILE)?;
59 std::fs::write(&path, snapshot.to_string())
60 .with_context(|| format!("failed to write {path}"))?;
61 Ok(())
62}
63
64pub fn undo() -> Result<()> {
68 let path = git::git_path(SNAPSHOT_FILE)?;
69 let Ok(contents) = std::fs::read_to_string(&path) else {
70 anyhow::bail!("nothing to undo");
71 };
72 let snapshot: Value = serde_json::from_str(&contents).context("failed to parse undo state")?;
73
74 if super::restack::in_progress() {
75 anyhow::bail!(
76 "a restack is in progress; finish with `git stk continue` or `git stk abort` first"
77 );
78 }
79 if !git::worktree_is_clean()? {
80 anyhow::bail!(
81 "worktree has uncommitted changes; commit or stash them before `git stk undo`"
82 );
83 }
84
85 let label = snapshot["label"].as_str().unwrap_or("the last operation");
86 let head = snapshot["head"].as_str().unwrap_or_default().to_owned();
87 let branches = snapshot["branches"].as_array().cloned().unwrap_or_default();
88
89 let blocked = blocked_by_other_worktrees(&branches, &head)?;
93 if !blocked.is_empty() {
94 anyhow::bail!(blocked_message(&blocked));
95 }
96
97 let mut restored = 0;
98 for entry in &branches {
99 let name = entry["name"].as_str().unwrap_or_default();
100 if name.is_empty() {
101 continue;
102 }
103
104 if let Some(sha) = entry["sha"].as_str() {
106 git::update_ref(name, sha)?;
107 }
108
109 restore_config(name, "stkParent", entry["parent"].as_str())?;
111 restore_config(name, "stkBase", entry["base"].as_str())?;
112 restore_config(name, "stkFloor", entry["floor"].as_str())?;
113 restored += 1;
114 }
115
116 if !head.is_empty() && git::branch_sha(&head).is_some() {
119 if git::current_branch().ok().as_deref() != Some(&head) {
120 git::checkout(&head)?;
121 }
122 git::reset_hard()?;
123 }
124
125 std::fs::remove_file(&path).ok();
126
127 anstream::println!(
128 "{}",
129 style::success(&format!("undid {label}: restored {restored} branches"))
130 );
131 anstream::println!(
132 "{}",
133 style::dim("local refs and metadata only; pushes and merged reviews are not reverted")
134 );
135 Ok(())
136}
137
138fn blocked_by_other_worktrees(
145 branches: &[Value],
146 head: &str,
147) -> Result<Vec<(String, std::path::PathBuf)>> {
148 let held = git::worktree_branches()?;
149 if held.is_empty() {
150 return Ok(Vec::new());
151 }
152 let holder = |branch: &str| {
153 held.iter()
154 .find(|(name, _)| name == branch)
155 .map(|(_, path)| path.clone())
156 };
157
158 let mut blocked = Vec::new();
159 for entry in branches {
160 let name = entry["name"].as_str().unwrap_or_default();
161 let Some(sha) = entry["sha"].as_str() else {
164 continue;
165 };
166 if name.is_empty() || git::branch_sha(name).as_deref() == Some(sha) {
167 continue;
168 }
169 if let Some(path) = holder(name) {
170 blocked.push((name.to_owned(), path));
171 }
172 }
173
174 if !head.is_empty()
177 && git::current_branch().ok().as_deref() != Some(head)
178 && !blocked.iter().any(|(name, _)| name == head)
179 && let Some(path) = holder(head)
180 {
181 blocked.push((head.to_owned(), path));
182 }
183
184 Ok(blocked)
185}
186
187fn blocked_message(blocked: &[(String, std::path::PathBuf)]) -> String {
188 let mut message = String::from("undo would rewind branches checked out in other worktrees:\n");
189 for (branch, path) in blocked {
190 message.push_str(&format!(" {branch} in {}\n", git::describe_worktree(path)));
191 }
192 let held_by = git::distinct_paths(blocked.iter().map(|(_, path)| path.as_path()));
193 message.push_str(
194 "those worktrees would keep an index and working tree the branch no longer matches. \
195 Free ",
196 );
197 message.push_str(if held_by.len() == 1 { "it" } else { "each one" });
200 message.push_str(" by detaching there, then re-run:\n");
201 for path in &held_by {
202 message.push_str(&format!(" {}\n", git::detach_command(path)));
203 }
204 message.truncate(message.trim_end().len());
205 message
206}
207
208fn restore_config(branch: &str, key: &str, value: Option<&str>) -> Result<()> {
209 let full = format!("branch.{branch}.{key}");
210 match value {
211 Some(value) => git::config_set(&full, value),
212 None => git::config_unset(&full),
213 }
214}