Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
213 changes: 204 additions & 9 deletions asyncgit/src/sync/branch/merge_rebase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommitId> {
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<bool> {
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<Option<Oid>> {
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,
Expand All @@ -46,6 +123,7 @@ mod test {
RepoState,
};
use git2::{Repository, Time};
use std::fs;

fn get_commit_msgs(r: &Repository) -> Vec<String> {
let commits = get_commit_ids(r, 10);
Expand Down Expand Up @@ -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());
}
}