use std::collections::HashSet;
use crate::change_id::ChangeId;
use crate::fetch::NoProgress;
use crate::merge_base::{
count_symmetric_ahead_behind, is_ancestor, merge_bases_first_vs_rest,
walk_commits_reachable_excluding_ancestors_of, ReachableWalkLimit,
};
use crate::objects::ObjectId;
use crate::operation::Conflict;
use crate::patch_ids::{compute_patch_id, PatchIdMode};
use crate::porcelain::checkout_reflog::checkout_reflog_identity;
use crate::porcelain::pull::{pull, FetchedUpstream, PullMode, PullOptions, PullResult};
use crate::porcelain::switch::{switch_repository, SwitchMode, SwitchOptions};
use crate::progress::NeverCancel;
use crate::remote::transfer::FetchOptions;
use crate::repo::Repository;
use crate::state::{resolve_head_for_repository, HeadState};
use super::ids::change_keys;
use super::refresh::ensure_refreshable;
use super::refs::{swap_ref_as, Swap};
use super::{
give_change_ids, upstream_ref, valid_branch_name, BranchInfo, CheckoutError, CheckoutMarker,
Result,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BranchRefreshResult {
Created,
UpToDate,
FastForwarded,
Rebased {
own: usize,
},
Stopped {
conflicts: Vec<Conflict>,
},
Conflicts {
conflicts: Vec<Conflict>,
},
AutostashConflict,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchRefresh {
pub name: String,
pub upstream_ref: String,
pub projected: ObjectId,
pub previous: Option<ObjectId>,
pub tip: ObjectId,
pub result: BranchRefreshResult,
pub switched: bool,
pub was_on: Option<String>,
pub gave_change_ids: usize,
pub check: bool,
pub ahead_behind: Option<(usize, usize)>,
}
struct OwnCommits {
from: ObjectId,
copies: Vec<ObjectId>,
own: usize,
}
pub fn refresh_branch(
repo: &Repository,
marker: &CheckoutMarker,
info: &BranchInfo,
check: bool,
) -> Result<BranchRefresh> {
ensure_refreshable(repo)?;
let name = info.name.as_str();
if !valid_branch_name(name) {
return Err(CheckoutError::InvalidBranchName(name.to_owned()));
}
let projected = ObjectId::from_hex(&info.projected).map_err(|e| CheckoutError::Protocol {
url: marker.url.clone(),
detail: format!(
"the projection {:?} is not a commit id: {e}",
info.projected
),
})?;
let fetched = crate::refs::resolve_ref(repo.git_dir(), &upstream_ref(name)).ok();
if fetched != Some(projected) {
return Err(CheckoutError::BranchMoved {
name: name.to_owned(),
expected: info.projected.clone(),
got: fetched
.map(|g| g.to_hex())
.unwrap_or_else(|| "nothing".to_owned()),
});
}
let refname = format!("refs/heads/{name}");
let gave_change_ids = if check {
0
} else {
give_change_ids(repo, marker, Some(&[name.to_owned()]), repo.identity_now())?
};
let head = resolve_head_for_repository(repo)?;
let was_on = match &head {
HeadState::Branch { short_name, .. } => Some(short_name.clone()),
_ => None,
};
let current = was_on.as_deref() == Some(name);
let done = |previous, tip, result| BranchRefresh {
name: name.to_owned(),
upstream_ref: upstream_ref(name),
projected,
previous,
tip,
result,
switched: false,
was_on: was_on.clone(),
gave_change_ids,
check,
ahead_behind: None,
};
let message = format!("pull: move to {}", upstream_label(name));
let Ok(tip) = crate::refs::resolve_ref(repo.git_dir(), &refname) else {
if !check {
let created = swap_ref_as(
repo,
&refname,
None,
projected,
&checkout_reflog_identity(repo),
&format!("branch: Created from {}", upstream_label(name)),
)?;
if created == Swap::Moved {
return Err(CheckoutError::RefKeptMoving { refname });
}
}
return Ok(BranchRefresh {
ahead_behind: check.then_some((0, info.commits)),
..done(None, projected, BranchRefreshResult::Created)
});
};
if tip == projected || is_ancestor(repo, projected, tip)? {
return Ok(BranchRefresh {
ahead_behind: check
.then(|| count_symmetric_ahead_behind(repo, tip, projected))
.transpose()?,
..done(Some(tip), tip, BranchRefreshResult::UpToDate)
});
}
let own = own_commits(repo, marker, name, tip, projected)?;
if own.own == 0 {
if check {
return Ok(BranchRefresh {
ahead_behind: Some(count_symmetric_ahead_behind(repo, tip, projected)?),
..done(Some(tip), tip, BranchRefreshResult::FastForwarded)
});
}
if !current {
ensure_not_checked_out_elsewhere(repo, name)?;
let moved = swap_ref_as(
repo,
&refname,
Some(tip),
projected,
&checkout_reflog_identity(repo),
&message,
)?;
if moved == Swap::Moved {
return Err(CheckoutError::RefKeptMoving { refname });
}
return Ok(done(
Some(tip),
projected,
BranchRefreshResult::FastForwarded,
));
}
}
if check {
return Ok(BranchRefresh {
ahead_behind: Some(count_symmetric_ahead_behind(repo, tip, projected)?),
..done(
Some(tip),
tip,
BranchRefreshResult::Rebased { own: own.own },
)
});
}
let mut switched = false;
if !current {
ensure_not_checked_out_elsewhere(repo, name)?;
switch_repository(
repo,
&SwitchOptions {
mode: SwitchMode::Existing {
branch: name.to_owned(),
oid: tip,
},
dry_run: false,
hooks: crate::hooks::PostOperationHooks::Run,
},
&mut crate::progress::NullProgress,
&NeverCancel,
)?;
switched = true;
}
let options = PullOptions {
mode: Some(PullMode::Rebase),
stop_on_conflict: true,
upstream: Some(FetchedUpstream {
label: upstream_label(name),
oid: projected,
landed: Some(own.from),
copies: own.copies,
}),
fetch: {
let mut opts = FetchOptions::new();
opts.thread_policy = repo.thread_policy();
opts
},
..Default::default()
};
let pulled = pull(repo, &options, &mut NoProgress, &NeverCancel, None);
let nothing_done = match &pulled {
Err(_) => true,
Ok(outcome) => matches!(
outcome.result,
PullResult::Conflicts | PullResult::RefusedNotFf
),
};
if switched && nothing_done && switch_back(repo, &head) {
switched = false;
}
let pulled = match pulled {
Ok(pulled) => pulled,
Err(failed) if switched => {
return Err(CheckoutError::StillSwitched {
branch: name.to_owned(),
failed: Box::new(failed.into()),
})
}
Err(failed) => return Err(failed.into()),
};
let after = pulled.oid.unwrap_or(tip);
let result = match pulled.result {
PullResult::UpToDate => BranchRefreshResult::UpToDate,
PullResult::FastForward | PullResult::Landed | PullResult::SetUpstream => {
BranchRefreshResult::FastForwarded
}
PullResult::Rebased | PullResult::Merged => BranchRefreshResult::Rebased { own: own.own },
PullResult::RebaseStopped | PullResult::RebaseConflicts => BranchRefreshResult::Stopped {
conflicts: pulled.conflicts.unwrap_or_default(),
},
PullResult::Conflicts | PullResult::RefusedNotFf => BranchRefreshResult::Conflicts {
conflicts: pulled.conflicts.unwrap_or_default(),
},
PullResult::AutostashConflict => BranchRefreshResult::AutostashConflict,
};
Ok(BranchRefresh {
switched,
..done(Some(tip), after, result)
})
}
fn switch_back(repo: &Repository, head: &HeadState) -> bool {
let mode = match head {
HeadState::Branch {
short_name,
oid: Some(oid),
..
} => SwitchMode::Existing {
branch: short_name.clone(),
oid: *oid,
},
HeadState::Detached { oid } => SwitchMode::Detached {
commit: *oid,
reflog_to_label: oid.to_hex(),
},
HeadState::Branch { oid: None, .. } | HeadState::Invalid => return false,
};
let options = SwitchOptions {
mode,
dry_run: false,
hooks: crate::hooks::PostOperationHooks::Run,
};
switch_repository(
repo,
&options,
&mut crate::progress::NullProgress,
&NeverCancel,
)
.is_ok()
}
fn upstream_label(name: &str) -> String {
format!("origin/{name}")
}
fn ensure_not_checked_out_elsewhere(repo: &Repository, name: &str) -> Result<()> {
Ok(crate::worktree::ensure_branch_not_checked_out_in_any_worktree(repo, name)?)
}
fn own_commits(
repo: &Repository,
marker: &CheckoutMarker,
name: &str,
tip: ObjectId,
projected: ObjectId,
) -> Result<OwnCommits> {
let roots: Vec<ObjectId> = marker
.roots
.iter()
.filter_map(|r| ObjectId::from_hex(&r.commit).ok())
.collect();
let met = merge_bases_first_vs_rest(repo, tip, &[projected])?
.into_iter()
.next();
let mut snapshot = None;
for root in roots.iter().rev() {
if is_ancestor(repo, *root, tip)? {
snapshot = Some(*root);
break;
}
}
let mut from = match (met, snapshot) {
(Some(m), Some(s)) if is_ancestor(repo, s, m)? => m,
(_, Some(s)) => s,
(Some(m), None) => m,
(None, None) => {
return Err(CheckoutError::UnrelatedBranch {
name: name.to_owned(),
})
}
};
let keys: HashSet<ChangeId> = change_keys(repo, &[projected], &roots)?
.into_iter()
.map(|(_, key)| key)
.collect();
let odb = repo.odb();
let algo = odb.hash_algo();
let (_, theirs) = walk_commits_reachable_excluding_ancestors_of(
repo,
&[projected],
&[tip],
ReachableWalkLimit::Unlimited,
)?;
let mut patches = HashSet::new();
for commit in theirs {
if let Some(patch) = compute_patch_id(algo, odb, &commit, PatchIdMode::Stable)? {
patches.insert(patch);
}
}
let mut copies = Vec::new();
let mut own = 0;
let mut leading = true;
for (commit, key) in change_keys(repo, &[tip], &[from])? {
let data = odb.read_commit(&commit)?;
let merge = data.parents.len() > 1;
let copy = !merge
&& (keys.contains(&key)
|| compute_patch_id(algo, odb, &commit, PatchIdMode::Stable)?
.is_some_and(|patch| patches.contains(&patch)));
if leading && copy && data.parents.first() == Some(&from) {
from = commit;
continue;
}
leading = false;
if copy {
copies.push(commit);
} else if !merge {
own += 1;
}
}
Ok(OwnCommits { from, copies, own })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::objects::{serialize_commit, ObjectKind};
use crate::snapshot_checkout::testing::*;
use crate::snapshot_checkout::{
branches_with_work, fetch_branch, project_branch, refresh, CheckoutStore,
};
use std::path::Path;
fn fetched(served: &Served, repo: &Repository, name: &str) -> BranchInfo {
let server = open(&served.dir);
let log = CheckoutStore::for_repository(&server).graft_log();
let info = project_branch(&server, &served.info(), &log, name)
.expect("project")
.expect("shown");
fetch_branch(
repo,
&marker(repo),
&info,
FetchOptions::default(),
&mut NoProgress,
&NeverCancel,
None,
)
.expect("fetch");
info
}
fn pulled(repo: &Repository, info: &BranchInfo) -> BranchRefresh {
refresh_branch(repo, &marker(repo), info, false).expect("refresh branch")
}
fn files(co: &Path, rev: &str) -> String {
git(co, &["ls-tree", "-r", "--name-only", rev])
}
#[cfg_attr(
not(feature = "full-tests"),
ignore = "starts system git or is heavy; run with --features full-tests"
)]
#[test]
fn a_server_branch_is_created_then_followed_then_rebased_under_own_commits() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut served = Served::new(tmp.path());
let (co, repo) = check_out(&mut served, tmp.path());
served.commit_on("feature", "src/a.rs", "a by bob\n", "Bob changes a");
served.commit_on(
"feature",
"docs/d.md",
"docs by bob\n",
"Bob changes the docs",
);
let info = fetched(&served, &repo, "feature");
assert_eq!(info.commits, 2);
let created = pulled(&repo, &info);
assert_eq!(created.result, BranchRefreshResult::Created);
assert_eq!(created.previous, None);
assert!(!created.switched);
assert_eq!(created.upstream_ref, "refs/remotes/origin/feature");
assert_eq!(git(&co, &["rev-parse", "feature"]), info.projected);
assert_eq!(git(&co, &["symbolic-ref", "--short", "HEAD"]), "work");
assert!(
!files(&co, "feature").contains("docs/"),
"outside the paths"
);
assert_eq!(
git(&co, &["log", "-1", "--format=%s", "feature"]),
"Bob changes the docs"
);
assert!(
branches_with_work(&repo, &marker(&repo))
.expect("branches")
.iter()
.any(|(name, _)| name == "feature"),
"a branch on projected commits is one to check in"
);
assert_eq!(pulled(&repo, &info).result, BranchRefreshResult::UpToDate);
served.commit_on("feature", "src/b.rs", "b by bob\n", "Bob changes b");
let info = fetched(&served, &repo, "feature");
let moved = pulled(&repo, &info);
assert_eq!(moved.result, BranchRefreshResult::FastForwarded);
assert!(!moved.switched);
assert_eq!(git(&co, &["rev-parse", "feature"]), info.projected);
git(&co, &["switch", "-q", "feature"]);
commit(&co, "src/c.rs", "c\n", "Add c");
let up = pulled(&repo, &info);
assert_eq!(up.result, BranchRefreshResult::UpToDate);
assert_eq!(up.gave_change_ids, 1, "the commit made with git got an id");
let ours = git(&co, &["rev-parse", "feature"]);
served.commit_on("feature", "src/a.rs", "a by bob, again\n", "Bob again");
let info = fetched(&served, &repo, "feature");
let would = refresh_branch(&repo, &marker(&repo), &info, true).expect("check");
assert_eq!(would.result, BranchRefreshResult::Rebased { own: 1 });
assert!(would.check);
assert_eq!(would.gave_change_ids, 0);
assert_eq!(
git(&co, &["rev-parse", "feature"]),
ours,
"a check changes nothing"
);
let rebased = pulled(&repo, &info);
assert_eq!(rebased.gave_change_ids, 0);
assert_eq!(rebased.result, BranchRefreshResult::Rebased { own: 1 });
assert!(!rebased.switched);
assert_eq!(git(&co, &["rev-parse", "feature~1"]), info.projected);
assert_eq!(git(&co, &["log", "-1", "--format=%s", "feature"]), "Add c");
assert_eq!(git(&co, &["status", "--porcelain"]), "");
assert_eq!(
std::fs::read_to_string(co.join("src/a.rs")).expect("read"),
"a by bob, again\n"
);
assert!(!co.join("docs").exists());
}
#[cfg_attr(
not(feature = "full-tests"),
ignore = "starts system git or is heavy; run with --features full-tests"
)]
#[test]
fn copies_of_the_servers_commits_are_left_out_by_change_id() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut served = Served::new(tmp.path());
let (co, repo) = check_out(&mut served, tmp.path());
served.commit_on("feature", "src/a.rs", "a by bob\n", "Bob changes a");
let info = fetched(&served, &repo, "feature");
pulled(&repo, &info);
git(&co, &["switch", "-q", "feature"]);
commit(&co, "src/c.rs", "c\n", "Add c");
let newer = served.moves("src/b.rs", "b2\n");
let options = crate::porcelain::pull::PullOptions::default();
refresh(
&repo,
&marker(&repo),
&newer,
&options,
&mut NoProgress,
&NeverCancel,
None,
)
.expect("refresh");
assert_eq!(git(&co, &["rev-parse", "feature~2"]), newer.root);
served.commit_on("feature", "src/a.rs", "a by bob, again\n", "Bob again");
let info = fetched(&served, &repo, "feature");
let theirs = oid(&info.projected);
let key = crate::change_id::resolved_change_id(
&theirs,
&repo.odb().read_commit(&theirs).expect("read"),
)
.id;
let tip = oid(&git(&co, &["rev-parse", "feature"]));
let mut copy = repo.odb().read_commit(&tip).expect("read");
copy.parents = vec![tip];
copy.message = "My copy of Bob again\n".to_owned();
crate::change_id::set_change_id(&mut copy, &key);
let copy = repo
.odb()
.write(ObjectKind::Commit, &serialize_commit(©))
.expect("copy");
git(&co, &["update-ref", "refs/heads/feature", ©.to_hex()]);
let rebased = pulled(&repo, &info);
assert_eq!(rebased.result, BranchRefreshResult::Rebased { own: 1 });
assert_eq!(git(&co, &["rev-parse", "feature~1"]), info.projected);
let log = git(&co, &["log", "--format=%s", "feature"]);
let subjects: Vec<&str> = log.lines().collect();
assert_eq!(subjects.len(), 4, "{log}");
assert_eq!(subjects[..3], ["Add c", "Bob again", "Bob changes a"]);
assert!(subjects[3].starts_with("Snapshot of"), "{log}");
assert_eq!(
git(&co, &["rev-parse", "feature~3"]),
served.roots[0].commit
);
}
#[cfg_attr(
not(feature = "full-tests"),
ignore = "starts system git or is heavy; run with --features full-tests"
)]
#[test]
fn a_conflicting_branch_refresh_stops_in_a_rebase() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut served = Served::new(tmp.path());
let (co, repo) = check_out(&mut served, tmp.path());
served.commit_on("feature", "src/b.rs", "b by bob\n", "Bob changes b");
let info = fetched(&served, &repo, "feature");
pulled(&repo, &info);
git(&co, &["switch", "-q", "feature"]);
commit(&co, "src/a.rs", "mine\n", "Mine");
served.commit_on("feature", "src/a.rs", "theirs\n", "Theirs");
let info = fetched(&served, &repo, "feature");
let stopped = pulled(&repo, &info);
let BranchRefreshResult::Stopped { conflicts } = &stopped.result else {
panic!("stopped: {stopped:?}");
};
assert_eq!(
conflicts
.iter()
.map(|c| c.path.as_str())
.collect::<Vec<_>>(),
["src/a.rs"]
);
assert!(co.join(".git/rebase-merge/grit-plan").is_file());
assert!(matches!(
refresh_branch(&repo, &marker(&repo), &info, false),
Err(CheckoutError::OperationInProgress(_))
));
assert_eq!(stopped.tip, stopped.previous.expect("previous"));
let identity = "T <t@example.com> 0 +0000".parse().expect("signature");
crate::revwalk::history::abort_rebase(&repo, &identity).expect("abort");
assert_eq!(git(&co, &["rev-parse", "feature"]), stopped.tip.to_hex());
assert_eq!(git(&co, &["log", "-1", "--format=%s", "feature"]), "Mine");
}
#[cfg_attr(
not(feature = "full-tests"),
ignore = "starts system git or is heavy; run with --features full-tests"
)]
#[test]
fn a_branch_being_rebased_in_another_worktree_is_left_to_that_rebase() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut served = Served::new(tmp.path());
let (co, repo) = check_out(&mut served, tmp.path());
served.commit_on("feature", "src/b.rs", "b by bob\n", "Bob changes b");
let info = fetched(&served, &repo, "feature");
pulled(&repo, &info);
git(&co, &["switch", "-q", "-c", "other", "feature"]);
commit(&co, "src/a.rs", "other\n", "Other");
git(&co, &["switch", "-q", "work"]);
let wt = tmp.path().join("wt");
git(
&co,
&[
"worktree",
"add",
"-q",
wt.to_str().expect("path"),
"feature",
],
);
commit(&wt, "src/a.rs", "mine\n", "Mine");
let ours = git(&co, &["rev-parse", "feature"]);
assert!(
git_output_fails(
&wt,
&[
"-c",
"user.name=T",
"-c",
"user.email=t@e",
"rebase",
"other"
]
),
"the rebase stops on a conflict"
);
assert!(co
.join(".git/worktrees/wt/rebase-merge/head-name")
.is_file());
served.commit_on("feature", "src/d.rs", "d by bob\n", "Bob adds d");
let info = fetched(&served, &repo, "feature");
let refused = refresh_branch(&repo, &marker(&repo), &info, false);
assert!(
matches!(
&refused,
Err(CheckoutError::Library(e))
if matches!(**e, crate::error::Error::BranchCheckedOutInWorktree { .. })
),
"{refused:?}"
);
assert_eq!(git(&co, &["rev-parse", "feature"]), ours);
assert_eq!(git(&co, &["symbolic-ref", "--short", "HEAD"]), "work");
std::fs::write(wt.join("src/a.rs"), "mine\n").expect("write");
git(&wt, &["add", "src/a.rs"]);
git(&wt, &["-c", "core.editor=true", "rebase", "--continue"]);
assert_eq!(git(&wt, &["symbolic-ref", "--short", "HEAD"]), "feature");
}
#[cfg_attr(
not(feature = "full-tests"),
ignore = "starts system git or is heavy; run with --features full-tests"
)]
#[test]
fn a_branch_that_is_not_checked_out_is_switched_to_for_its_rebase() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut served = Served::new(tmp.path());
let (co, repo) = check_out(&mut served, tmp.path());
served.commit_on("feature", "src/a.rs", "a by bob\n", "Bob changes a");
let info = fetched(&served, &repo, "feature");
pulled(&repo, &info);
git(&co, &["switch", "-q", "feature"]);
commit(&co, "src/c.rs", "c\n", "Add c");
git(&co, &["switch", "-q", "work"]);
served.commit_on("feature", "src/b.rs", "b by bob\n", "Bob changes b");
let info = fetched(&served, &repo, "feature");
let rebased = pulled(&repo, &info);
assert_eq!(rebased.result, BranchRefreshResult::Rebased { own: 1 });
assert!(rebased.switched);
assert_eq!(rebased.was_on.as_deref(), Some("work"));
assert_eq!(git(&co, &["symbolic-ref", "--short", "HEAD"]), "feature");
assert_eq!(git(&co, &["rev-parse", "feature~1"]), info.projected);
let lone = git(&co, &["commit-tree", "-m", "lone", "HEAD^{tree}"]);
git(&co, &["branch", "lone", &lone]);
served.commit_on("lone", "src/l.rs", "l\n", "Server lone");
let info = fetched(&served, &repo, "lone");
assert!(matches!(
refresh_branch(&repo, &marker(&repo), &info, false),
Err(CheckoutError::UnrelatedBranch { .. })
));
let mut stale = info.clone();
stale.projected = "1".repeat(40);
assert!(matches!(
refresh_branch(&repo, &marker(&repo), &stale, false),
Err(CheckoutError::BranchMoved { .. })
));
}
#[cfg_attr(
not(feature = "full-tests"),
ignore = "starts system git or is heavy; run with --features full-tests"
)]
#[test]
fn a_rebase_that_refuses_after_the_switch_switches_back() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut served = Served::new(tmp.path());
let (co, repo) = check_out(&mut served, tmp.path());
served.commit_on("feature", "src/b.rs", "b by bob\n", "Bob changes b");
let info = fetched(&served, &repo, "feature");
pulled(&repo, &info);
let before = info.projected.clone();
git(&co, &["switch", "-q", "feature"]);
commit(&co, "src/c.rs", "c\n", "Add c");
git(&co, &["switch", "-q", "work"]);
std::fs::write(co.join("src/a.rs"), "dirty\n").expect("write");
served.commit_on("feature", "src/a.rs", "a by bob\n", "Bob changes a");
let info = fetched(&served, &repo, "feature");
let refused = refresh_branch(&repo, &marker(&repo), &info, false);
assert!(
matches!(refused, Err(CheckoutError::Pull(_))),
"{refused:?}"
);
assert_eq!(git(&co, &["symbolic-ref", "--short", "HEAD"]), "work");
assert_eq!(git(&co, &["rev-parse", "feature~1"]), before, "not rebased");
assert_eq!(git(&co, &["log", "-1", "--format=%s", "feature"]), "Add c");
assert_eq!(
std::fs::read_to_string(co.join("src/a.rs")).expect("read"),
"dirty\n"
);
assert_eq!(git(&co, &["status", "--porcelain"]), "M src/a.rs");
}
}