use crate::merge_base::{is_ancestor, merge_bases_first_vs_rest};
use crate::objects::ObjectId;
use crate::porcelain::branch::{rename_branch, RenameBranchOptions};
use crate::porcelain::branch_error::BranchError;
use crate::repo::Repository;
use crate::state::{resolve_head_for_repository, HeadState};
use super::{CheckoutClient, CheckoutError, CheckoutMarker, IntegrateResponse, Result};
const NAME_ATTEMPTS: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovedOff {
pub from: String,
pub to: String,
pub tip: ObjectId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Landing {
Here {
name: String,
tip: Option<ObjectId>,
},
Elsewhere {
target: String,
},
}
impl Landing {
pub fn move_off(
&self,
repo: &Repository,
client: &CheckoutClient<'_>,
wanted: &str,
) -> Result<Option<MovedOff>> {
match self {
Self::Here { name, .. } => move_off_landed_branch(repo, client, name, wanted),
Self::Elsewhere { .. } => Ok(None),
}
}
}
impl CheckoutMarker {
pub fn record_landed(&mut self, tip: ObjectId) -> bool {
let hex = tip.to_hex();
if self.landed.contains(&hex) {
return false;
}
self.landed.push(hex);
true
}
fn landed_tips(&self) -> impl Iterator<Item = ObjectId> + '_ {
self.landed
.iter()
.filter_map(|tip| ObjectId::from_hex(tip).ok())
}
}
pub fn record_landed(
repo: &Repository,
marker: &CheckoutMarker,
tip: ObjectId,
) -> Result<CheckoutMarker> {
let mut current = CheckoutMarker::read(repo.git_dir()).unwrap_or_else(|| marker.clone());
if current.record_landed(tip) {
current.write(repo.git_dir())?;
}
Ok(current)
}
pub fn land_integration(
repo: &Repository,
marker: &CheckoutMarker,
response: &IntegrateResponse,
checked_in: Option<ObjectId>,
) -> Result<Landing> {
if response.target != marker.branch {
return Ok(Landing::Elsewhere {
target: response.target.clone(),
});
}
if let Some(tip) = checked_in {
record_landed(repo, marker, tip)?;
}
Ok(Landing::Here {
name: response.name.clone(),
tip: checked_in,
})
}
pub(crate) fn landed_base(
repo: &Repository,
marker: &CheckoutMarker,
tip: ObjectId,
root: ObjectId,
) -> Result<Option<ObjectId>> {
if marker.landed.is_empty() {
return Ok(None);
}
let Some(start) = merge_bases_first_vs_rest(repo, tip, &[root])?
.into_iter()
.next()
else {
return Ok(None);
};
let mut base = start;
for landed in marker.landed_tips() {
if !repo.odb().exists(&landed) {
continue;
}
let Some(met) = merge_bases_first_vs_rest(repo, tip, &[landed])?
.into_iter()
.next()
else {
continue;
};
if met != base && is_ancestor(repo, base, met)? {
base = met;
}
}
Ok((base != start).then_some(base))
}
pub(crate) fn prune_landed(repo: &Repository, root: ObjectId) -> Result<()> {
let Some(mut marker) = CheckoutMarker::read(repo.git_dir()) else {
return Ok(());
};
if marker.landed.is_empty() {
return Ok(());
}
let mut tips: Vec<ObjectId> = crate::refs::list_refs(repo.git_dir(), "refs/heads/")?
.into_iter()
.map(|(_, tip)| tip)
.collect();
tips.extend(
crate::worktree::list_worktrees(repo)?
.into_iter()
.filter_map(|worktree| match worktree.head {
HeadState::Detached { oid } => Some(oid),
_ => None,
}),
);
let mut kept = Vec::new();
for landed in marker.landed_tips() {
if repo.odb().exists(&landed) && builds_on(repo, &tips, landed, root)? {
kept.push(landed.to_hex());
}
}
if kept != marker.landed {
marker.landed = kept;
marker.write(repo.git_dir())?;
}
Ok(())
}
fn builds_on(
repo: &Repository,
tips: &[ObjectId],
landed: ObjectId,
root: ObjectId,
) -> Result<bool> {
for &tip in tips {
if let Some(met) = merge_bases_first_vs_rest(repo, tip, &[landed])?
.into_iter()
.next()
{
if !is_ancestor(repo, met, root)? {
return Ok(true);
}
}
}
Ok(false)
}
pub fn move_off_landed_branch(
repo: &Repository,
client: &CheckoutClient<'_>,
branch: &str,
wanted: &str,
) -> Result<Option<MovedOff>> {
let Some(marker) = CheckoutMarker::read(repo.git_dir()) else {
return Ok(None);
};
let Some(root) = marker
.last()
.and_then(|last| ObjectId::from_hex(&last.commit).ok())
else {
return Ok(None);
};
let on_branch = matches!(
resolve_head_for_repository(repo)?,
HeadState::Branch { short_name, oid: Some(tip), .. } if short_name == branch && tip == root
);
if !on_branch {
return Ok(None);
}
let mut last = String::new();
for _ in 0..NAME_ATTEMPTS {
let name = client.reserve_work_branch(&marker.id, wanted)?;
let renamed = rename_branch(
repo,
&RenameBranchOptions {
old: branch.to_owned(),
new: name.clone(),
force: false,
dry_run: false,
},
);
match renamed {
Ok(_) => {
return Ok(Some(MovedOff {
from: branch.to_owned(),
to: name,
tip: root,
}))
}
Err(crate::error::Error::Branch(BranchError::AlreadyExists { .. })) => last = name,
Err(e) => return Err(e.into()),
}
}
Err(CheckoutError::WorkBranchTaken { name: last })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repo::InitOptions;
use crate::snapshot_checkout::IntegrateStatus;
fn marker() -> CheckoutMarker {
CheckoutMarker {
url: "http://127.0.0.1/demo.git".to_owned(),
id: "0123456789abcdef0123".to_owned(),
branch: "main".to_owned(),
paths: Vec::new(),
roots: Vec::new(),
landed: Vec::new(),
}
}
#[test]
fn a_landed_tip_is_recorded_once_and_old_markers_read_without_any() {
let tip = ObjectId::from_hex("1111111111111111111111111111111111111111").expect("oid");
let mut m = marker();
assert!(m.record_landed(tip));
assert!(!m.record_landed(tip));
assert_eq!(m.landed, [tip.to_hex()]);
let mut old = serde_json::to_value(marker()).expect("json");
assert!(old.get("landed").is_none(), "an empty list is not written");
old["roots"] = serde_json::json!([]);
let read: CheckoutMarker = serde_json::from_value(old).expect("parse");
assert!(read.landed.is_empty());
}
fn answer(target: &str, status: IntegrateStatus) -> IntegrateResponse {
IntegrateResponse {
status,
name: "alice/work".to_owned(),
target: target.to_owned(),
previous: "2222222222222222222222222222222222222222".to_owned(),
head: "3333333333333333333333333333333333333333".to_owned(),
commits: 2,
}
}
fn landed(repo: &Repository) -> Vec<String> {
CheckoutMarker::read(repo.git_dir())
.expect("a checkout")
.landed
}
#[test]
fn only_work_merged_into_the_branch_the_checkout_snapshots_lands() {
let dir = tempfile::tempdir().expect("tempdir");
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
marker().write(repo.git_dir()).expect("marker");
let tip = ObjectId::from_hex("1111111111111111111111111111111111111111").expect("oid");
for target in ["develop", "Main"] {
let landing = land_integration(
&repo,
&marker(),
&answer(target, IntegrateStatus::Merged),
Some(tip),
)
.expect("land");
assert_eq!(
landing,
Landing::Elsewhere {
target: target.to_owned()
}
);
assert!(landed(&repo).is_empty(), "{target}");
}
for status in [IntegrateStatus::Merged, IntegrateStatus::AlreadyIntegrated] {
let landing = land_integration(&repo, &marker(), &answer("main", status), Some(tip))
.expect("land");
assert_eq!(
landing,
Landing::Here {
name: "alice/work".to_owned(),
tip: Some(tip),
}
);
assert_eq!(landed(&repo), [tip.to_hex()]);
}
let again = land_integration(
&repo,
&marker(),
&answer("main", IntegrateStatus::AlreadyIntegrated),
None,
)
.expect("land");
assert_eq!(
again,
Landing::Here {
name: "alice/work".to_owned(),
tip: None,
}
);
assert_eq!(landed(&repo), [tip.to_hex()]);
}
}