diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ca0eaac2..43eb511f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * support x509 commit signing [[@kaden-l-nelson](https://github.com/kaden-l-nelson)] ([#2514](https://github.com/gitui-org/gitui/issues/2514)) +* honor the `rebase.autoStash` git config when pulling with rebase, so a dirty working tree no longer blocks the pull ([#3018](https://github.com/gitui-org/gitui/issues/3018)) ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting diff --git a/asyncgit/src/sync/branch/merge_rebase.rs b/asyncgit/src/sync/branch/merge_rebase.rs index 3e243c4aa4..4e869a311d 100644 --- a/asyncgit/src/sync/branch/merge_rebase.rs +++ b/asyncgit/src/sync/branch/merge_rebase.rs @@ -7,37 +7,114 @@ use crate::{ RepoPath, }, }; -use git2::BranchType; +use git2::{BranchType, ErrorCode, Oid, Repository, StashFlags}; use scopetime::scope_time; /// tries merging current branch with its upstream using rebase +/// +/// honors the `rebase.autoStash` git config: when it's enabled and the +/// working tree carries uncommitted changes to tracked files, those +/// changes are stashed before the rebase and re-applied afterwards, so a +/// pull doesn't fail just because the tree is dirty. pub fn merge_upstream_rebase( repo_path: &RepoPath, branch_name: &str, ) -> Result { scope_time!("merge_upstream_rebase"); - let repo = repo(repo_path)?; + let mut repo = repo(repo_path)?; if super::get_branch_name_repo(&repo)? != branch_name { return Err(Error::Generic(String::from( "can only rebase in head branch", ))); } - let branch = repo.find_branch(branch_name, BranchType::Local)?; - let upstream = branch.upstream()?; - let upstream_commit = upstream.get().peel_to_commit()?; - let annotated_upstream = - repo.find_annotated_commit(upstream_commit.id())?; + let upstream_id = { + let branch = + repo.find_branch(branch_name, BranchType::Local)?; + let upstream = branch.upstream()?; + upstream.get().peel_to_commit()?.id() + }; + + let autostash = if rebase_autostash_enabled(&repo)? { + autostash_save(&mut repo)? + } else { + None + }; + + let rebase_result = { + let annotated_upstream = + repo.find_annotated_commit(upstream_id)?; + conflict_free_rebase(&repo, &annotated_upstream) + }; + + if let Some(stash_id) = autostash { + // always restore the autostash, whether the rebase finished or + // failed. if the rebase aborted, its HEAD is back where it + // started and the pop simply restores the dirty tree; if it + // succeeded, the changes are re-applied on top of the new HEAD. + let pop_result = autostash_pop(&mut repo, stash_id); + // surface the rebase failure first, if any: the pop has already + // put the user's changes back for them. + let commit = rebase_result?; + // a conflicting pop leaves the stash entry in place (git2 only + // drops it on a clean apply), matching git's autostash behavior. + pop_result?; + Ok(commit) + } else { + rebase_result + } +} + +/// reads the `rebase.autoStash` bool git config (defaults to false) +fn rebase_autostash_enabled(repo: &Repository) -> Result { + Ok(repo.config()?.get_bool("rebase.autoStash").unwrap_or(false)) +} - conflict_free_rebase(&repo, &annotated_upstream) +/// stashes tracked changes ahead of an autostash rebase. returns `None` +/// when the tree is clean and nothing needed stashing. untracked files +/// are left alone, matching git's autostash. +fn autostash_save(repo: &mut Repository) -> Result> { + let signature = repo.signature()?; + + match repo.stash_save2( + &signature, + Some("gitui: autostash before rebase"), + Some(StashFlags::DEFAULT), + ) { + Ok(id) => Ok(Some(id)), + // nothing to stash: the tree was clean + Err(e) if e.code() == ErrorCode::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// pops the autostash entry identified by `stash_id` +fn autostash_pop(repo: &mut Repository, stash_id: Oid) -> Result<()> { + let mut index = None; + repo.stash_foreach(|i, _msg, id| { + if *id == stash_id { + index = Some(i); + false + } else { + true + } + })?; + + let index = index.ok_or_else(|| { + Error::Generic(String::from("autostash entry not found")) + })?; + + repo.stash_pop(index, None)?; + + Ok(()) } #[cfg(test)] mod test { use super::*; use crate::sync::{ - branch_compare_upstream, get_commits_info, + branch_compare_upstream, get_commits_info, get_stashes, remotes::{fetch, push::push_branch}, tests::{ debug_cmd_print, get_commit_ids, repo_clone, @@ -46,6 +123,7 @@ mod test { RepoState, }; use git2::{Repository, Time}; + use std::fs; fn get_commit_msgs(r: &Repository) -> Vec { let commits = get_commit_ids(r, 10); @@ -353,4 +431,121 @@ mod test { vec![String::from("commit3"), String::from("commit1")] ); } + + // sets up a bare origin plus a clone that sits one commit ahead of + // and one commit behind its upstream (so a rebase actually has a + // local commit to replay), and returns the clone dir + repo. + // `upstream_file` is the file the behind-by-one upstream commit adds. + fn setup_diverged_clone( + upstream_file: &str, + ) -> (tempfile::TempDir, tempfile::TempDir, Repository) { + let (r1_dir, _repo) = repo_init_bare().unwrap(); + let origin = r1_dir.path().to_str().unwrap(); + + let (clone1_dir, clone1) = repo_clone(origin).unwrap(); + write_commit_file(&clone1, "test.txt", "base", "commit1"); + push_branch( + &clone1_dir.path().to_str().unwrap().into(), + "origin", + "master", + false, + false, + None, + None, + ) + .unwrap(); + + let (clone2_dir, clone2) = repo_clone(origin).unwrap(); + write_commit_file(&clone2, upstream_file, "up", "commit2"); + push_branch( + &clone2_dir.path().to_str().unwrap().into(), + "origin", + "master", + false, + false, + None, + None, + ) + .unwrap(); + + // local commit that isn't pushed yet: this is what gets rebased + write_commit_file(&clone1, "local.txt", "local", "commit3"); + + let clone1_path = clone1_dir.path().to_str().unwrap(); + fetch(&clone1_path.into(), "master", None, None).unwrap(); + let cmp = + branch_compare_upstream(&clone1_path.into(), "master") + .unwrap(); + assert_eq!(cmp.behind, 1); + assert_eq!(cmp.ahead, 1); + + (clone1_dir, clone2_dir, clone1) + } + + #[test] + fn test_autostash_restores_dirty_tree() { + let (clone1_dir, _clone2_dir, clone1) = + setup_diverged_clone("upstream.txt"); + let clone1_path = clone1_dir.path().to_str().unwrap(); + + clone1 + .config() + .unwrap() + .set_bool("rebase.autoStash", true) + .unwrap(); + + // leave an uncommitted change on a tracked file + let dirty = clone1.workdir().unwrap().join("test.txt"); + fs::write(&dirty, "dirty").unwrap(); + + merge_upstream_rebase(&clone1_path.into(), "master").unwrap(); + + // upstream commit got rebased in and the tree is clean again + assert_eq!( + crate::sync::repo_state(&clone1_path.into()).unwrap(), + RepoState::Clean + ); + assert_eq!( + get_commit_msgs(&clone1), + vec![ + String::from("commit3"), + String::from("commit2"), + String::from("commit1") + ] + ); + + // the dirty change was popped back and no stash entry lingers + assert_eq!(fs::read_to_string(&dirty).unwrap(), "dirty"); + assert!(get_stashes(&clone1_path.into()).unwrap().is_empty()); + } + + #[test] + fn test_autostash_noop_on_clean_tree() { + let (clone1_dir, _clone2_dir, clone1) = + setup_diverged_clone("upstream.txt"); + let clone1_path = clone1_dir.path().to_str().unwrap(); + + clone1 + .config() + .unwrap() + .set_bool("rebase.autoStash", true) + .unwrap(); + + // tree is clean, so nothing should be stashed + merge_upstream_rebase(&clone1_path.into(), "master").unwrap(); + + assert_eq!( + crate::sync::repo_state(&clone1_path.into()).unwrap(), + RepoState::Clean + ); + assert_eq!( + get_commit_msgs(&clone1), + vec![ + String::from("commit3"), + String::from("commit2"), + String::from("commit1") + ] + ); + assert!(get_stashes(&clone1_path.into()).unwrap().is_empty()); + } }