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, 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            })
47        })
48        .collect();
49
50    let snapshot = json!({
51        "label": label,
52        "head": head,
53        "branches": branches,
54    });
55    let path = git::git_path(SNAPSHOT_FILE)?;
56    std::fs::write(&path, snapshot.to_string())
57        .with_context(|| format!("failed to write {path}"))?;
58    Ok(())
59}
60
61/// Restore the most recent snapshot: reset branch tips and metadata to their
62/// pre-mutation state. Refuses on a dirty worktree (it resets the current
63/// branch) and consumes the snapshot so it is one-shot.
64pub fn undo() -> Result<()> {
65    let path = git::git_path(SNAPSHOT_FILE)?;
66    let Ok(contents) = std::fs::read_to_string(&path) else {
67        anyhow::bail!("nothing to undo");
68    };
69    let snapshot: Value = serde_json::from_str(&contents).context("failed to parse undo state")?;
70
71    if super::restack::in_progress() {
72        anyhow::bail!(
73            "a restack is in progress; finish with `git stk continue` or `git stk abort` first"
74        );
75    }
76    if !git::worktree_is_clean()? {
77        anyhow::bail!(
78            "worktree has uncommitted changes; commit or stash them before `git stk undo`"
79        );
80    }
81
82    let label = snapshot["label"].as_str().unwrap_or("the last operation");
83    let head = snapshot["head"].as_str().unwrap_or_default().to_owned();
84    let branches = snapshot["branches"].as_array().cloned().unwrap_or_default();
85
86    // Before the first ref moves: nothing here is recoverable per-branch, so a
87    // blocked restore has to fail whole. The snapshot survives the bail, so
88    // freeing the worktree and re-running works.
89    let blocked = blocked_by_other_worktrees(&branches, &head)?;
90    if !blocked.is_empty() {
91        anyhow::bail!(blocked_message(&blocked));
92    }
93
94    let mut restored = 0;
95    for entry in &branches {
96        let name = entry["name"].as_str().unwrap_or_default();
97        if name.is_empty() {
98            continue;
99        }
100
101        // Refs first: recreate deleted branches, rewind moved ones.
102        if let Some(sha) = entry["sha"].as_str() {
103            git::update_ref(name, sha)?;
104        }
105
106        // Then metadata, set or cleared to match the snapshot.
107        restore_config(name, "stkParent", entry["parent"].as_str())?;
108        restore_config(name, "stkBase", entry["base"].as_str())?;
109        restored += 1;
110    }
111
112    // Put HEAD back where it was and sync the worktree to the restored tip
113    // (clean-tree precondition makes this lossless).
114    if !head.is_empty() && git::branch_sha(&head).is_some() {
115        if git::current_branch().ok().as_deref() != Some(&head) {
116            git::checkout(&head)?;
117        }
118        git::reset_hard()?;
119    }
120
121    std::fs::remove_file(&path).ok();
122
123    anstream::println!(
124        "{}",
125        style::success(&format!("undid {label}: restored {restored} branches"))
126    );
127    anstream::println!(
128        "{}",
129        style::dim("local refs and metadata only; pushes and merged reviews are not reverted")
130    );
131    Ok(())
132}
133
134/// Snapshot branches the restore would move that another worktree holds.
135/// `update_ref` succeeds on those without complaint, leaving that worktree's
136/// index and working tree describing a commit its branch no longer points at -
137/// it silently acquires staged changes nobody made. Refusing keeps `undo` as
138/// conservative as its clean-tree precondition already implies: the other
139/// worktree may hold uncommitted work, and the snapshot does not cover it.
140fn blocked_by_other_worktrees(
141    branches: &[Value],
142    head: &str,
143) -> Result<Vec<(String, std::path::PathBuf)>> {
144    let held = git::worktree_branches()?;
145    if held.is_empty() {
146        return Ok(Vec::new());
147    }
148    let holder = |branch: &str| {
149        held.iter()
150            .find(|(name, _)| name == branch)
151            .map(|(_, path)| path.clone())
152    };
153
154    let mut blocked = Vec::new();
155    for entry in branches {
156        let name = entry["name"].as_str().unwrap_or_default();
157        // Only refs that actually move: one already at its recorded sha changes
158        // nothing in the worktree holding it.
159        let Some(sha) = entry["sha"].as_str() else {
160            continue;
161        };
162        if name.is_empty() || git::branch_sha(name).as_deref() == Some(sha) {
163            continue;
164        }
165        if let Some(path) = holder(name) {
166            blocked.push((name.to_owned(), path));
167        }
168    }
169
170    // The restore ends by checking `head` out. Another worktree holding it
171    // fails that checkout too - after every ref has already been rewound.
172    if !head.is_empty()
173        && git::current_branch().ok().as_deref() != Some(head)
174        && !blocked.iter().any(|(name, _)| name == head)
175        && let Some(path) = holder(head)
176    {
177        blocked.push((head.to_owned(), path));
178    }
179
180    Ok(blocked)
181}
182
183fn blocked_message(blocked: &[(String, std::path::PathBuf)]) -> String {
184    let mut message = String::from("undo would rewind branches checked out in other worktrees:\n");
185    for (branch, path) in blocked {
186        message.push_str(&format!("  {branch} in {}\n", git::describe_worktree(path)));
187    }
188    let held_by = git::distinct_paths(blocked.iter().map(|(_, path)| path.as_path()));
189    message.push_str(
190        "those worktrees would keep an index and working tree the branch no longer matches. \
191         Free ",
192    );
193    // Detaching rather than removing: the main worktree can hold a branch too,
194    // and `git worktree remove` refuses on it.
195    message.push_str(if held_by.len() == 1 { "it" } else { "each one" });
196    message.push_str(" by detaching there, then re-run:\n");
197    for path in &held_by {
198        message.push_str(&format!("  {}\n", git::detach_command(path)));
199    }
200    message.truncate(message.trim_end().len());
201    message
202}
203
204fn restore_config(branch: &str, key: &str, value: Option<&str>) -> Result<()> {
205    let full = format!("branch.{branch}.{key}");
206    match value {
207        Some(value) => git::config_set(&full, value),
208        None => git::config_unset(&full),
209    }
210}