git_quickfix/
lib.rs

1use eyre::{eyre, Result};
2use std::process;
3
4use git2::{self, Repository, RepositoryState, ResetType};
5use process::Command;
6
7use color_eyre::{eyre::Report, Section};
8
9extern crate log;
10
11/// Wraps the three steps below into one, such that any error can be caught and
12/// the git stash can be popped before exiting.
13/// In many ways this is a poor-person's try-finally (or context manager in Python).
14/// Using Drop led to multiple borrowing errors. Any improvements along those lines
15/// are more than welcome.
16pub fn wrapper_pick_and_clean(
17    repo: &Repository,
18    target_branch: &str,
19    onto_branch: &str,
20    force_new_branch: bool,
21) -> Result<()> {
22    assure_workspace_is_clean(repo)
23        .suggestion("Consider auto-stashing your changes with --autostash.")
24        .suggestion("Running this again with RUST_LOG=debug provides more details.")?;
25    cherrypick_commit_onto_new_branch(repo, target_branch, onto_branch, force_new_branch)?;
26    remove_commit_from_head(repo)?;
27    Ok(())
28}
29
30/// The main functional function.
31///
32/// Please read the README for some further background.
33pub fn cherrypick_commit_onto_new_branch(
34    repo: &Repository,
35    target_branch: &str,
36    onto_branch: &str,
37    force_new_branch: bool,
38) -> Result<(), Report> {
39    let main_commit = repo
40        .revparse(onto_branch)?
41        .from()
42        .unwrap()
43        .peel_to_commit()?;
44
45    // Create the new branch
46    let new_branch = repo
47        .branch(target_branch, &main_commit, force_new_branch)
48        .suggestion("Consider using --force to overwrite the existing branch")?;
49
50    // Cherry-pick the HEAD onto the main branch but in memory.
51    // Then create a new branch with that cherry-picked commit.
52    let fix_commit = repo.head()?.peel_to_commit()?;
53    if fix_commit.parent_count() != 1 {
54        return Err(eyre!("Only works with non-merge commits"))
55            .suggestion("Quickfixing a merge commit is not supported. If you meant to do this please file a ticket with your use case.");
56    };
57
58    // Cherry-pick (in memory)
59    let mut index = repo.cherrypick_commit(&fix_commit, &main_commit, 0, None)?;
60    let tree_oid = index.write_tree_to(repo)?;
61    let tree = repo.find_tree(tree_oid)?;
62
63    // The author is copied from the original commit. But the committer is set to the current user and timestamp.
64    let signature = repo.signature()?;
65    let message = fix_commit
66        .message_raw()
67        .ok_or_else(|| eyre!("Could not read the commit message."))
68        .suggestion("Make sure the commit message contains only UTF-8 characters or try to manually cherry-pick the commit.")?;
69
70    let commit_oid = repo
71        .commit(
72            new_branch.get().name(),
73            &fix_commit.author(),
74            &signature,
75            message,
76            &tree,
77            &[&main_commit],
78        )
79        .suggestion(
80            "You cannot provide an existing branch name. Choose a new branch name or run with '--force'.",
81        )?; // TODO: How do I make sure this suggestion only gets shown if ErrorClass==Object and ErrorCode==-15?
82    log::debug!(
83        "Wrote quickfixed changes to new commit {} and new branch {}",
84        commit_oid,
85        target_branch
86    );
87
88    Ok(())
89}
90
91/// Removes the last commit from the current branch.
92fn remove_commit_from_head(repo: &Repository) -> Result<(), Report> {
93    // Equivalent to git reset --hard HEAD~1
94    let head_1 = repo.head()?.peel_to_commit()?.parent(0)?;
95    repo.reset(head_1.as_object(), ResetType::Hard, None)?;
96
97    Ok(())
98}
99
100/// Pushes <branch> as new branch to `origin`. Other remote names are currently
101/// not supported. If there is a need, please let us know.
102pub fn push_new_commit(repo: &Repository, branch: &str) -> Result<(), Report> {
103    // TODO: Use git2 instead of Command.
104    let workdir = repo
105        .workdir()
106        .ok_or_else(|| eyre!("Could not get workdir"))?;
107
108    log::info!("Pushing new branch to origin.");
109    let status = Command::new("git")
110        .args(&["push", "--set-upstream", "origin", branch])
111        .current_dir(workdir)
112        .status()?;
113    if !status.success() {
114        Err(eyre!("Failed to run git push. {}", status))
115    } else {
116        log::info!("Git push succeeded");
117        Ok(())
118    }
119}
120
121/// Checks that repo is in "RepositoryState::Clean" state. This means there is
122/// no rebase, cherry-pick, merge, etc is in progress. Confusingly, this is different
123/// from no uncommitted or staged changes being present in the repo. For this,
124/// see [fn.assure_workspace_is_clean].
125pub fn assure_repo_in_normal_state(repo: &Repository) -> Result<()> {
126    let state = repo.state();
127    if state != RepositoryState::Clean {
128        return Err(eyre!(
129            "The repository is currently not in a clean state ({:?}).",
130            state
131        ));
132    }
133
134    Ok(())
135}
136
137/// Checks that the workspace is clean. (No staged or unstaged changes.)
138fn assure_workspace_is_clean(repo: &Repository) -> Result<()> {
139    let mut options = git2::StatusOptions::new();
140    options.include_ignored(false);
141    let statuses = repo.statuses(Some(&mut options))?;
142    for s in statuses.iter() {
143        log::warn!("Dirty: {:?} -- {:?}", s.path(), s.status());
144    }
145    let is_dirty = !statuses.is_empty();
146    if is_dirty {
147        Err(eyre!("The repository is dirty."))
148    } else {
149        Ok(())
150    }
151}
152
153/// Inspiration from here: https://github.com/siedentop/git-quickfix/issues/11
154/// Essentially: "$(git branch -rl '*/HEAD' | awk '{print $3}')"
155fn get_default_branch_from_head(repo: &Repository) -> Result<String> {
156    let workdir = repo
157        .workdir()
158        .ok_or_else(|| eyre!("Could not get workdir"))?;
159    let output = Command::new("git")
160        .args(&["branch", "-rl", "*/HEAD"])
161        .current_dir(workdir)
162        .output()?;
163    let status = output.status;
164    if !status.success() {
165        return Err(eyre!("Failed to run git branch -rl. {}", status));
166    }
167
168    let output = String::from_utf8_lossy(&output.stdout);
169    let head_reference = output.split("->").nth(1);
170    if let Some(head_reference) = head_reference {
171        let head_reference = head_reference.trim();
172        Ok(head_reference.to_string())
173    } else {
174        Err(eyre!("Could not find a default branch."))
175    }
176}
177
178/// Resolve the default branch name on the 'origin' remote.
179/// First tries looking for where origin/HEAD is pointing to. If that fails
180/// tries a hardcoded list of possible branches.
181pub fn get_default_branch(repo: &Repository) -> Result<String, Report> {
182    match get_default_branch_from_head(repo) {
183        Ok(branch) => return Ok(branch),
184        Err(e) => {
185            log::debug!(
186                "Failed to get default branch from HEAD: {}. Using hardcoded branches",
187                e
188            );
189        }
190    }
191
192    // NOTE: Unfortunately, I cannot use repo.find_remote().default_branch() because it requires a connect() before.
193    // Furthermore, a lot is to be said about returning a Reference or a Revspec instead of a String.
194    for name in [
195        "origin/main",
196        "origin/master",
197        "origin/devel",
198        "origin/develop",
199    ]
200    .iter()
201    {
202        match repo.resolve_reference_from_short_name(name) {
203            Ok(_) => {
204                log::debug!("Found {} as the default remote branch. A bit hacky -- wrong results certainly possible.", name);
205                return Ok(name.to_string());
206            }
207            Err(_) => continue,
208        }
209    }
210    Err(eyre!("Could not find remote default branch."))
211}
212
213/// Returns Ok(true) if stashing was successful. Ok(false) if stashing was not needed.
214pub fn stash(repo: &mut Repository) -> Result<bool> {
215    let signature = repo.signature()?;
216    // Apologies for this code. This is just a fancy way of filtering out the (Stash, NotFound) error.
217    let stashed = match repo.stash_save(&signature, "quickfix: auto-stash", None) {
218        Ok(stash) => {
219            log::debug!("Stashed to object {}", stash);
220            true
221        }
222        Err(e) => {
223            // Accept if there is nothing to stash.
224            if e.code() == git2::ErrorCode::NotFound && e.class() == git2::ErrorClass::Stash {
225                log::debug!("Nothing to stash.");
226                false
227            } else {
228                return Err(eyre!("{}", e.message()));
229            }
230        }
231    };
232
233    Ok(stashed)
234}
235
236#[cfg(test)]
237mod test {
238    use super::*;
239
240    #[test]
241    fn test_default_branch() {
242        let repo = Repository::open(".").unwrap();
243        let branch = get_default_branch(&repo).unwrap();
244        assert_eq!(branch, "origin/main");
245    }
246}