From e21e75d47ec0df267b8eb2713a411236de88809d Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:07:52 -0400 Subject: [PATCH] honor rebase.autoStash when pulling with rebase libgit2 doesn't autostash for rebase, so a pull-rebase failed whenever the working tree had uncommitted changes. When rebase.autoStash is set, stash tracked changes before the rebase and pop them afterwards. If the rebase aborts we still restore the tree; if the pop conflicts the stash is kept, matching git. --- CHANGELOG.md | 1 + asyncgit/src/sync/branch/merge_rebase.rs | 213 ++++++++++++++++++++++- 2 files changed, 205 insertions(+), 9 deletions(-) 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()); + } }