git_stack/legacy/git/
commands.rs

1#[derive(Clone, Default, Debug, PartialEq, Eq, PartialOrd, Ord)]
2pub struct Script {
3    pub commands: Vec<Command>,
4    pub dependents: Vec<Script>,
5}
6
7impl Script {
8    pub fn new() -> Self {
9        Default::default()
10    }
11
12    pub fn is_empty(&self) -> bool {
13        self.commands.is_empty() && self.dependents.is_empty()
14    }
15
16    pub fn branch(&self) -> Option<&str> {
17        for command in self.commands.iter().rev() {
18            if let Command::CreateBranch(name) = command {
19                return Some(name);
20            }
21        }
22
23        None
24    }
25
26    pub fn dependent_branches(&self) -> Vec<&str> {
27        let mut branches = Vec::new();
28        for dependent in self.dependents.iter() {
29            branches.push(dependent.branch().unwrap_or("detached"));
30            branches.extend(dependent.dependent_branches());
31        }
32        branches
33    }
34
35    pub fn is_branch_deleted(&self, branch: &str) -> bool {
36        for command in &self.commands {
37            if let Command::DeleteBranch(ref current) = command {
38                if branch == current {
39                    return true;
40                }
41            }
42        }
43
44        for dependent in &self.dependents {
45            if dependent.is_branch_deleted(branch) {
46                return true;
47            }
48        }
49
50        false
51    }
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
55pub enum Command {
56    /// Switch to an existing commit
57    SwitchCommit(git2::Oid),
58    /// Mark the current commit with an `Oid` for future reference
59    RegisterMark(git2::Oid),
60    /// Switch to a previously registered marked commit
61    SwitchMark(git2::Oid),
62    /// Cherry-pick an existing commit
63    CherryPick(git2::Oid),
64    /// Squash a commit into prior commit, keeping the parent commits identity
65    Fixup(git2::Oid),
66    /// Mark a branch for creation at the current commit
67    CreateBranch(String),
68    /// Mark a branch for deletion
69    DeleteBranch(String),
70}
71
72pub struct Executor {
73    head_oid: git2::Oid,
74    marks: std::collections::HashMap<git2::Oid, git2::Oid>,
75    branches: Vec<(git2::Oid, String)>,
76    delete_branches: Vec<String>,
77    post_rewrite: Vec<(git2::Oid, git2::Oid)>,
78    dry_run: bool,
79    detached: bool,
80}
81
82impl Executor {
83    pub fn new(repo: &dyn crate::legacy::git::Repo, dry_run: bool) -> Executor {
84        let head_oid = repo.head_commit().id;
85        Self {
86            head_oid,
87            marks: Default::default(),
88            branches: Default::default(),
89            delete_branches: Default::default(),
90            post_rewrite: Default::default(),
91            dry_run,
92            detached: false,
93        }
94    }
95
96    pub fn run_script<'s>(
97        &mut self,
98        repo: &mut dyn crate::legacy::git::Repo,
99        script: &'s Script,
100    ) -> Vec<(git2::Error, &'s str, Vec<&'s str>)> {
101        let mut failures = Vec::new();
102        let branch_name = script.branch().unwrap_or("detached");
103
104        log::trace!("Applying `{}`", branch_name);
105        log::trace!("Script: {:#?}", script.commands);
106        #[allow(clippy::disallowed_methods)]
107        let res = script
108            .commands
109            .iter()
110            .try_for_each(|command| self.stage_single(repo, command));
111        match res.and_then(|_| self.commit(repo)) {
112            Ok(()) => {
113                log::trace!("         `{}` succeeded", branch_name);
114                for dependent in script.dependents.iter() {
115                    failures.extend(self.run_script(repo, dependent));
116                }
117                if !failures.is_empty() {
118                    log::trace!("         `{}`'s dependent failed", branch_name);
119                }
120            }
121            Err(err) => {
122                log::trace!("         `{}` failed: {}", branch_name, err);
123                self.abandon(repo);
124                failures.push((err, branch_name, script.dependent_branches()));
125            }
126        }
127
128        failures
129    }
130
131    pub fn stage_single(
132        &mut self,
133        repo: &mut dyn crate::legacy::git::Repo,
134        command: &Command,
135    ) -> Result<(), git2::Error> {
136        match command {
137            Command::SwitchCommit(oid) => {
138                let commit = repo.find_commit(*oid).ok_or_else(|| {
139                    git2::Error::new(
140                        git2::ErrorCode::NotFound,
141                        git2::ErrorClass::Reference,
142                        format!("could not find commit {oid:?}"),
143                    )
144                })?;
145                log::trace!("git checkout {}  # {}", oid, commit.summary);
146                self.head_oid = *oid;
147            }
148            Command::RegisterMark(mark_oid) => {
149                let target_oid = self.head_oid;
150                self.marks.insert(*mark_oid, target_oid);
151            }
152            Command::SwitchMark(mark_oid) => {
153                let oid = *self
154                    .marks
155                    .get(mark_oid)
156                    .expect("We only switch to marks that are created");
157
158                let commit = repo.find_commit(oid).unwrap();
159                log::trace!("git checkout {}  # {}", oid, commit.summary);
160                self.head_oid = oid;
161            }
162            Command::CherryPick(cherry_oid) => {
163                let cherry_commit = repo.find_commit(*cherry_oid).ok_or_else(|| {
164                    git2::Error::new(
165                        git2::ErrorCode::NotFound,
166                        git2::ErrorClass::Reference,
167                        format!("could not find commit {cherry_oid:?}"),
168                    )
169                })?;
170                log::trace!(
171                    "git cherry-pick {}  # {}",
172                    cherry_oid,
173                    cherry_commit.summary
174                );
175                let updated_oid = if self.dry_run {
176                    *cherry_oid
177                } else {
178                    repo.cherry_pick(self.head_oid, *cherry_oid)?
179                };
180                self.post_rewrite.push((*cherry_oid, updated_oid));
181                self.head_oid = updated_oid;
182            }
183            Command::Fixup(squash_oid) => {
184                let cherry_commit = repo.find_commit(*squash_oid).ok_or_else(|| {
185                    git2::Error::new(
186                        git2::ErrorCode::NotFound,
187                        git2::ErrorClass::Reference,
188                        format!("could not find commit {squash_oid:?}"),
189                    )
190                })?;
191                log::trace!(
192                    "git merge --squash {}  # {}",
193                    squash_oid,
194                    cherry_commit.summary
195                );
196                let updated_oid = if self.dry_run {
197                    *squash_oid
198                } else {
199                    repo.squash(*squash_oid, self.head_oid)?
200                };
201                for (_old_oid, new_oid) in &mut self.post_rewrite {
202                    if *new_oid == self.head_oid {
203                        *new_oid = updated_oid;
204                    }
205                }
206                self.post_rewrite.push((*squash_oid, updated_oid));
207                self.head_oid = updated_oid;
208            }
209            Command::CreateBranch(name) => {
210                let branch_oid = self.head_oid;
211                self.branches.push((branch_oid, name.to_owned()));
212            }
213            Command::DeleteBranch(name) => {
214                self.delete_branches.push(name.to_owned());
215            }
216        }
217
218        Ok(())
219    }
220
221    pub fn commit(&mut self, repo: &mut dyn crate::legacy::git::Repo) -> Result<(), git2::Error> {
222        let hook_repo = repo.path().map(git2::Repository::open).transpose()?;
223        let hooks = if self.dry_run {
224            None
225        } else {
226            hook_repo
227                .as_ref()
228                .map(git2_ext::hooks::Hooks::with_repo)
229                .transpose()?
230        };
231
232        log::trace!("Running reference-transaction hook");
233        let reference_transaction = self.branches.clone();
234        let reference_transaction: Vec<(git2::Oid, git2::Oid, &str)> = reference_transaction
235            .iter()
236            .map(|(new_oid, name)| {
237                // HACK: relying on "force updating the reference regardless of its current value" part
238                // of rules rather than tracking the old value
239                let old_oid = git2::Oid::zero();
240                (old_oid, *new_oid, name.as_str())
241            })
242            .collect();
243        let reference_transaction =
244            if let (Some(hook_repo), Some(hooks)) = (hook_repo.as_ref(), hooks.as_ref()) {
245                Some(
246                    hooks
247                        .run_reference_transaction(hook_repo, &reference_transaction)
248                        .map_err(|err| {
249                            git2::Error::new(
250                                git2::ErrorCode::GenericError,
251                                git2::ErrorClass::Os,
252                                err.to_string(),
253                            )
254                        })?,
255                )
256            } else {
257                None
258            };
259
260        if !self.branches.is_empty() || !self.delete_branches.is_empty() {
261            // In case we are changing the branch HEAD is attached to
262            if !self.dry_run {
263                repo.detach()?;
264                self.detached = true;
265            }
266
267            for (oid, name) in self.branches.iter() {
268                let commit = repo.find_commit(*oid).unwrap();
269                log::trace!("git checkout {}  # {}", oid, commit.summary);
270                log::trace!("git switch -c {}", name);
271                if !self.dry_run {
272                    repo.branch(name, *oid)?;
273                }
274            }
275        }
276        self.branches.clear();
277
278        for name in self.delete_branches.iter() {
279            log::trace!("git branch -D {}", name);
280            if !self.dry_run {
281                repo.delete_branch(name)?;
282            }
283        }
284        self.delete_branches.clear();
285
286        if let Some(tx) = reference_transaction {
287            tx.committed();
288        }
289        self.post_rewrite.retain(|(old, new)| old != new);
290        if !self.post_rewrite.is_empty() {
291            log::trace!("Running post-rewrite hook");
292            if let (Some(hook_repo), Some(hooks)) = (hook_repo.as_ref(), hooks.as_ref()) {
293                hooks.run_post_rewrite_rebase(hook_repo, &self.post_rewrite);
294            }
295            self.post_rewrite.clear();
296        }
297
298        Ok(())
299    }
300
301    pub fn abandon(&mut self, repo: &dyn crate::legacy::git::Repo) {
302        self.head_oid = repo.head_commit().id;
303        self.branches.clear();
304        self.delete_branches.clear();
305        self.post_rewrite.clear();
306    }
307
308    pub fn close(
309        &mut self,
310        repo: &mut dyn crate::legacy::git::Repo,
311        restore_branch: &str,
312    ) -> Result<(), git2::Error> {
313        assert_eq!(&self.branches, &[]);
314        assert_eq!(self.delete_branches, Vec::<String>::new());
315        log::trace!("git switch {}", restore_branch);
316        if !self.dry_run {
317            if self.detached {
318                repo.switch(restore_branch)?;
319            }
320            self.head_oid = repo.head_commit().id;
321        }
322
323        Ok(())
324    }
325}