Skip to main content

git_stk/stack/
snapshot.rs

1//! Undo support: capture the current stack's branch tips and metadata
2//! before a mutating command rewrites them, and restore that capture on
3//! `git stk undo`. Local only - pushes and platform merges are not
4//! reverted.
5
6use 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
17// One snapshot per process: the outermost mutating command captures state;
18// inner calls (sync's restack, merge's sync) must not overwrite it.
19static TAKEN: AtomicBool = AtomicBool::new(false);
20
21/// Record the current stack so `undo` can restore it. The `label` names the
22/// operation being undone. No-ops after the first call in a process, and is
23/// best effort: a snapshot failure never blocks the command itself.
24pub fn take(label: &str) {
25    if TAKEN.swap(true, Ordering::Relaxed) {
26        return;
27    }
28    if let Err(error) = capture(label) {
29        // The command should still run; we just lose undo for it.
30        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                // Undoing a command that rooted a stack has to unmark the base
47                // it marked, or the branch stays held out of every stack.
48                "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
64/// Restore the most recent snapshot: reset branch tips and metadata to their
65/// pre-mutation state. Refuses on a dirty worktree (it resets the current
66/// branch) and consumes the snapshot so it is one-shot.
67pub 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    // Before the first ref moves: nothing here is recoverable per-branch, so a
90    // blocked restore has to fail whole. The snapshot survives the bail, so
91    // freeing the worktree and re-running works.
92    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        // Refs first: recreate deleted branches, rewind moved ones.
105        if let Some(sha) = entry["sha"].as_str() {
106            git::update_ref(name, sha)?;
107        }
108
109        // Then metadata, set or cleared to match the snapshot.
110        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    // Put HEAD back where it was and sync the worktree to the restored tip
117    // (clean-tree precondition makes this lossless).
118    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
138/// Snapshot branches the restore would move that another worktree holds.
139/// `update_ref` succeeds on those without complaint, leaving that worktree's
140/// index and working tree describing a commit its branch no longer points at -
141/// it silently acquires staged changes nobody made. Refusing keeps `undo` as
142/// conservative as its clean-tree precondition already implies: the other
143/// worktree may hold uncommitted work, and the snapshot does not cover it.
144fn 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        // Only refs that actually move: one already at its recorded sha changes
162        // nothing in the worktree holding it.
163        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    // The restore ends by checking `head` out. Another worktree holding it
175    // fails that checkout too - after every ref has already been rewound.
176    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    // Detaching rather than removing: the main worktree can hold a branch too,
198    // and `git worktree remove` refuses on it.
199    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}