pub fn restricted() -> crate::open::Options {
crate::open::Options::isolated().config_overrides(["user.name=gitoxide", "user.email=gitoxide@localhost"])
}
fn hex_to_id(hex: &str) -> gix_hash::ObjectId {
gix_hash::ObjectId::from_hex(hex.as_bytes()).expect("valid hex object id")
}
mod update {
use gix_testtools::TestResult;
use super::hex_to_id;
use crate as gix;
fn base_repo_path() -> String {
gix::path::realpath(
gix_testtools::scripted_fixture_read_only("make_remote_repos.sh")
.unwrap()
.join("base"),
)
.unwrap()
.to_string_lossy()
.into_owned()
}
fn repo(name: &str) -> gix::Repository {
let dir = gix_testtools::scripted_fixture_read_only_with_args_single_archive(
"make_fetch_repos.sh",
[base_repo_path()],
)
.unwrap();
gix::open_opts(dir.join(name), restricted()).unwrap()
}
fn named_repo(name: &str) -> gix::Repository {
let dir = gix_testtools::scripted_fixture_read_only("make_remote_repos.sh").unwrap();
gix::open_opts(dir.join(name), restricted()).unwrap()
}
fn repo_rw(name: &str) -> (gix::Repository, gix_testtools::tempfile::TempDir) {
let dir = gix_testtools::scripted_fixture_writable_with_args_single_archive(
"make_fetch_repos.sh",
[base_repo_path()],
gix_testtools::Creation::Execute,
)
.unwrap();
let repo = gix::open_opts(dir.path().join(name), restricted()).unwrap();
(repo, dir)
}
fn peeled_id(repo: &gix::Repository, name: &str) -> gix_hash::ObjectId {
repo.find_reference(name)
.expect("reference exists")
.peel_to_id()
.expect("ref peels to an object")
.detach()
}
use gix_ref::{
Target, TargetRef,
transaction::{Change, PreviousValue, RefEdit},
};
use crate::{
bstr::BString,
remote::{
fetch,
fetch::{
RefLogMessage,
refmap::{Mapping, Source, SpecIndex},
refs::{tests::restricted, update::TypeChange},
},
},
};
#[test]
fn various_valid_updates() {
let repo = repo("two-origins");
for (spec, expected_mode, reflog_message, detail) in [
(
"refs/heads/main:refs/remotes/origin/main",
fetch::refs::update::Mode::NoChangeNeeded,
Some("no update will be performed"),
"these refs are en-par since the initial clone",
),
(
"refs/heads/main",
fetch::refs::update::Mode::NoChangeNeeded,
None,
"without local destination ref there is nothing to do for us, ever (except for FETCH_HEADs) later",
),
(
"refs/heads/main:refs/remotes/origin/new-main",
fetch::refs::update::Mode::New,
Some("storing ref"),
"the destination branch doesn't exist and needs to be created",
),
(
"refs/heads/main:refs/heads/feature",
fetch::refs::update::Mode::New,
Some("storing head"),
"reflog messages are specific to the type of branch stored, to some limited extend",
),
(
"refs/heads/main:refs/tags/new-tag",
fetch::refs::update::Mode::New,
Some("storing tag"),
"reflog messages are specific to the type of branch stored, to some limited extend",
),
(
"+refs/heads/main:refs/remotes/origin/new-main",
fetch::refs::update::Mode::New,
Some("storing ref"),
"just to validate that we really are in dry-run mode, or else this ref would be present now",
),
(
"+refs/heads/main:refs/remotes/origin/g",
fetch::refs::update::Mode::FastForward,
Some("fast-forward (guessed in dry-run)"),
"a forced non-fastforward (main goes backwards), but dry-run calls it fast-forward",
),
(
"+refs/heads/main:refs/tags/b-tag",
fetch::refs::update::Mode::Forced,
Some("updating tag"),
"tags can only be forced",
),
(
"refs/heads/main:refs/tags/b-tag",
fetch::refs::update::Mode::RejectedTagUpdate,
None,
"otherwise a tag is always refusing itself to be overwritten (no-clobber)",
),
(
"+refs/remotes/origin/g:refs/heads/main",
fetch::refs::update::Mode::RejectedCurrentlyCheckedOut {
worktree_dirs: vec![repo.current_dir().join(repo.workdir().expect("present"))],
},
None,
"checked out branches cannot be written, as it requires a merge of sorts which isn't done here",
),
(
"refs/remotes/origin/g:refs/heads/not-currently-checked-out",
fetch::refs::update::Mode::FastForward,
Some("fast-forward (guessed in dry-run)"),
"a fast-forward only fast-forward situation, all good",
),
] {
let (mapping, specs) = mapping_from_spec(spec, &repo);
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mapping,
&specs,
&[],
fetch::Tags::None,
reflog_message.map_or(fetch::DryRun::No, |_| fetch::DryRun::Yes),
fetch::WritePackedRefs::Never,
)
.unwrap();
assert_eq!(
out.updates,
vec![fetch::refs::Update {
type_change: None,
mode: expected_mode.clone(),
edit_index: reflog_message.map(|_| 0),
}],
"{spec:?}: {detail}"
);
assert_eq!(out.edits.len(), reflog_message.map_or(0, |_| 1));
if let Some(reflog_message) = reflog_message {
let edit = &out.edits[0];
match &edit.change {
Change::Update { log, new, .. } => {
assert_eq!(
log.message,
format!("action: {reflog_message}"),
"{spec}: reflog messages are specific and we emulate git word for word"
);
let remote_ref = repo
.find_reference(specs[0].to_ref().source().expect("always present"))
.unwrap();
assert_eq!(
new.id(),
remote_ref.target().id(),
"remote ref provides the id to set in the local reference"
);
}
_ => unreachable!("only updates"),
}
}
}
let missing = "f".repeat(repo.object_hash().len_in_hex());
let (mapping, specs) = mapping_from_spec(&format!("{missing}:refs/heads/invalid-source-object"), &repo);
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mapping,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::No,
fetch::WritePackedRefs::Never,
)
.unwrap();
assert_eq!(
out.updates,
vec![fetch::refs::Update {
type_change: None,
mode: fetch::refs::update::Mode::RejectedSourceObjectNotFound {
id: hex_to_id(&missing),
},
edit_index: None,
}],
"a source object that isn't present is rejected"
);
assert_eq!(out.edits.len(), 0);
}
#[test]
fn checked_out_branches_in_worktrees_are_rejected_with_additional_information() -> TestResult {
let root = gix_path::realpath(gix_testtools::scripted_fixture_read_only_with_args_single_archive(
"make_fetch_repos.sh",
[base_repo_path()],
)?)?;
let repo = root.join("worktree-root");
let repo = gix::open_opts(repo, restricted())?;
for (branch, path_from_root) in [
("main", "worktree-root"),
("wt-a-nested", "prev/wt-a-nested"),
("wt-a", "wt-a"),
("nested-wt-b", "wt-a/nested-wt-b"),
("wt-c-locked", "wt-c-locked"),
("wt-deleted", "wt-deleted"),
] {
let spec = format!("refs/heads/main:refs/heads/{branch}");
let (mappings, specs) = mapping_from_spec(&spec, &repo);
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)?;
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::RejectedCurrentlyCheckedOut {
worktree_dirs: vec![root.join(path_from_root)],
},
type_change: None,
edit_index: None,
}],
"{spec}: checked-out checks are done before checking if a change would actually be required (here it isn't)"
);
assert_eq!(out.edits.len(), 0);
}
Ok(())
}
#[test]
fn incomplete_linked_worktrees_without_a_head_are_ignored() -> TestResult {
let (repo, _tmp) = repo_rw("two-origins");
let git_dir = repo.common_dir().join("worktrees/incomplete");
std::fs::create_dir_all(&git_dir)?;
std::fs::write(git_dir.join("gitdir"), b"missing/.git\n")?;
let (mappings, specs) = mapping_from_spec("refs/heads/main:refs/remotes/origin/incomplete", &repo);
let update = || {
fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)
};
let expected = vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::New,
type_change: None,
edit_index: Some(0),
}];
assert_eq!(
update()?.updates,
expected,
"the incomplete worktree contributes no checked-out branch"
);
std::fs::write(git_dir.join("locked"), b"still in use\n")?;
assert_eq!(
update()?.updates,
expected,
"locking does not make an unreadable head protect a branch"
);
std::fs::remove_file(git_dir.join("locked"))?;
std::fs::write(git_dir.join("commondir"), b"missing\n")?;
assert_eq!(
update()?.updates,
expected,
"the parent repository supplies the authoritative common directory"
);
Ok(())
}
#[test]
fn unborn_remote_branches_can_be_created_locally_if_they_are_new() -> TestResult {
let repo = named_repo("unborn");
let (mappings, specs) = mapping_from_spec("HEAD:refs/remotes/origin/HEAD", &repo);
assert_eq!(mappings.len(), 1);
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)?;
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::New,
type_change: None,
edit_index: Some(0)
}]
);
assert_eq!(out.edits.len(), 1, "we are OK with creating unborn refs");
Ok(())
}
#[test]
fn unborn_remote_branches_can_update_local_unborn_branches() -> TestResult {
let repo = named_repo("unborn");
let peel_err = repo
.find_reference("refs/heads/existing-unborn-symbolic")?
.peel_to_id()
.expect_err("the local symbolic reference points to a missing branch");
insta::assert_debug_snapshot!(peel_err, "the missing reference remains available for update recovery", @r#"The ref partially named "refs/heads/main" could not be found"#);
assert!(
peel_err.downcast_any_ref::<gix_ref::file::find::NotFound>().is_some(),
"the missing reference remains available for update recovery"
);
let (mappings, specs) = mapping_from_spec("HEAD:refs/heads/existing-unborn-symbolic", &repo);
assert_eq!(mappings.len(), 1);
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)?;
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::NoChangeNeeded,
type_change: None,
edit_index: Some(0)
}]
);
assert_eq!(out.edits.len(), 1, "we are OK with updating unborn refs");
assert_eq!(
out.edits[0],
RefEdit::update(
"refs/heads/existing-unborn-symbolic".try_into()?,
Target::Symbolic("refs/heads/main".try_into()?),
PreviousValue::MustExistAndMatch(Target::Symbolic("refs/heads/main".try_into()?,)),
"action: change unborn ref",
)
);
let (mappings, specs) = mapping_from_spec("HEAD:refs/heads/existing-unborn-symbolic-other", &repo);
assert_eq!(mappings.len(), 1);
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)?;
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::Forced,
type_change: None,
edit_index: Some(0)
}]
);
assert_eq!(
out.edits.len(),
1,
"we are OK with creating unborn refs even without actually forcing it"
);
assert_eq!(
out.edits[0],
RefEdit::update(
"refs/heads/existing-unborn-symbolic-other".try_into()?,
Target::Symbolic("refs/heads/main".try_into()?),
PreviousValue::MustExistAndMatch(Target::Symbolic("refs/heads/other".try_into()?,)),
"action: change unborn ref",
)
);
Ok(())
}
#[test]
fn remote_symbolic_refs_with_locally_unavailable_target_result_in_valid_peeled_branches() -> TestResult {
let remote_repo = named_repo("one-commit-with-symref");
let local_repo = named_repo("unborn");
let (mappings, specs) = mapping_from_spec("refs/heads/symbolic:refs/heads/new", &remote_repo);
assert_eq!(mappings.len(), 1);
let out = fetch::refs::update(
&local_repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)?;
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::New,
type_change: None,
edit_index: Some(0)
}]
);
assert_eq!(out.edits.len(), 1);
let target = Target::Object(peeled_id(&remote_repo, "refs/heads/symbolic"));
assert_eq!(
out.edits[0],
RefEdit::update(
"refs/heads/new".try_into()?,
target.clone(),
PreviousValue::ExistingMustMatch(target),
"action: storing head",
),
"we create local-refs whose targets aren't present yet, even though the remote knows them.\
This leaves the caller with assuring all refs are mentioned in mappings."
);
Ok(())
}
#[test]
fn remote_symbolic_refs_with_locally_unavailable_target_dont_overwrite_valid_local_branches() -> TestResult {
let remote_repo = named_repo("one-commit-with-symref");
let local_repo = named_repo("one-commit-with-symref-missing-branch");
let (mappings, specs) = mapping_from_spec("refs/heads/unborn:refs/heads/valid-locally", &remote_repo);
assert_eq!(mappings.len(), 1);
let out = fetch::refs::update(
&local_repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)?;
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::RejectedToReplaceWithUnborn,
type_change: None,
edit_index: None
}]
);
assert_eq!(out.edits.len(), 0);
Ok(())
}
#[test]
fn unborn_remote_refs_dont_overwrite_valid_local_refs() -> TestResult {
let remote_repo = named_repo("unborn");
let local_repo = named_repo("one-commit-with-symref");
let (mappings, specs) =
mapping_from_spec("refs/heads/existing-unborn-symbolic:refs/heads/branch", &remote_repo);
assert_eq!(mappings.len(), 1);
let out = fetch::refs::update(
&local_repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)?;
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::RejectedToReplaceWithUnborn,
type_change: None,
edit_index: None
}],
"we don't overwrite locally present refs with unborn ones for safety"
);
assert_eq!(out.edits.len(), 0);
Ok(())
}
#[test]
fn symbolic_tags_with_malformed_referents_are_not_unborn() -> TestResult {
let mut diagnostics = Vec::new();
let (repo, _tmp) = repo_rw("two-origins");
std::fs::write(repo.git_dir().join("refs/tags/broken"), b"ref: refs/tags/malformed\n")?;
let (mappings, specs) = mapping_from_spec("refs/heads/main:refs/tags/broken", &repo);
std::fs::write(repo.git_dir().join("refs/tags/malformed"), b"invalid")?;
for dry_run in [fetch::DryRun::Yes, fetch::DryRun::No] {
let err = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
dry_run,
fetch::WritePackedRefs::Never,
)
.expect_err("malformed referents must not be treated as unborn");
diagnostics.push(gix_testtools::redact_debug_snapshot(&err, &[]));
assert!(err.is_corrupted(), "the original decode failure is propagated");
assert!(err.downcast_any_ref::<gix_ref::file::find::ReferenceDecode>().is_some());
}
assert_eq!(
repo.find_reference("refs/tags/broken")?.target().into_owned(),
Target::Symbolic("refs/tags/malformed".try_into()?),
"the failed update preserves the symbolic tag"
);
insta::assert_debug_snapshot!(diagnostics, "symbolic tags with malformed referents are not unborn", @r#"
[
Could not peel symbolic local reference to its ID
Caused by:
0: The reference at "refs/tags/malformed" could not be decoded
1: Reference content could not be parsed, input="invalid",
Could not peel symbolic local reference to its ID
Caused by:
0: The reference at "refs/tags/malformed" could not be decoded
1: Reference content could not be parsed, input="invalid",
]
"#);
Ok(())
}
#[test]
fn symbolic_tags_with_missing_objects_are_not_unborn() -> TestResult {
let mut diagnostics = Vec::new();
let (repo, _tmp) = repo_rw("two-origins");
let worktree = repo.workdir().expect("fixture has a worktree");
let missing_id = hex_to_id(&"1".repeat(repo.object_hash().len_in_hex()));
std::fs::write(repo.git_dir().join("refs/tags/broken"), b"ref: refs/tags/missing\n")?;
std::fs::write(repo.git_dir().join("refs/tags/missing"), format!("{missing_id}\n"))?;
let output = gix_testtools::git_command(worktree)
.args(["fetch", "--no-tags", "origin", "refs/heads/main:refs/tags/broken"])
.output()?;
assert!(
!output.status.success(),
"Git rejects replacing the broken tag without force"
);
insta::assert_debug_snapshot!(gix_testtools::redact_debug_snapshot(&format_args!("{}", String::from_utf8_lossy(&output.stderr)), &[(&_tmp.path().canonicalize()?.to_string_lossy(), "<fixture>"), (&_tmp.path().to_string_lossy(), "<fixture>")]), "Git rejects the fetch because the symbolic tag points to a missing object", @"
fatal: bad object refs/tags/broken
error: <fixture>/base did not send all necessary objects
");
let (mappings, specs) = mapping_from_spec("refs/heads/main:refs/tags/broken", &repo);
for dry_run in [fetch::DryRun::Yes, fetch::DryRun::No] {
let err = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
dry_run,
fetch::WritePackedRefs::Never,
)
.expect_err("a missing object is a peeling failure, not an unborn reference");
diagnostics.push(gix_testtools::redact_debug_snapshot(&err, &[]));
assert!(
err.is_not_found()
&& err.metadata().any(|details| details.get("object_id")
== Some(&gix_error::MetadataValue::from(missing_id.to_string()))),
"the missing-object peeling error is propagated: {err:?}"
);
}
assert_eq!(
repo.find_reference("refs/tags/broken")?.target().into_owned(),
Target::Symbolic("refs/tags/missing".try_into()?),
"the symbolic tag is preserved"
);
insta::assert_debug_snapshot!(diagnostics, "both dry-run and real fetches retain the missing-object peeling failure", @r#"
[
Could not peel symbolic local reference to its ID
Caused by:
0: Could not peel reference to an object: object could not be found, object_id="Oid(1)", reference="refs/tags/missing",
Could not peel symbolic local reference to its ID
Caused by:
0: Could not peel reference to an object: object could not be found, object_id="Oid(1)", reference="refs/tags/missing",
]
"#);
Ok(())
}
#[test]
fn local_symbolic_refs_can_be_overwritten() {
let repo = repo("two-origins");
let main_id = peeled_id(&repo, "refs/heads/main");
for (source, destination, expected_update, expected_edit) in [
(
"refs/heads/symbolic",
"HEAD",
fetch::refs::Update {
mode: fetch::refs::update::Mode::New,
type_change: None,
edit_index: Some(0),
},
Some(RefEdit::update(
"refs/heads/HEAD".try_into().expect("valid"),
main_id,
PreviousValue::ExistingMustMatch(Target::Object(main_id)),
"action: storing head",
)),
),
(
"refs/remotes/origin/b", "refs/heads/main",
fetch::refs::Update {
mode: fetch::refs::update::Mode::RejectedCurrentlyCheckedOut {
worktree_dirs: vec![repo.current_dir().join(repo.workdir().expect("present"))],
},
type_change: None,
edit_index: None,
},
None,
),
(
"refs/heads/main",
"refs/heads/symbolic",
fetch::refs::Update {
mode: fetch::refs::update::Mode::NoChangeNeeded,
type_change: Some(TypeChange::SymbolicToDirect),
edit_index: Some(0),
},
Some(RefEdit::update(
"refs/heads/symbolic".try_into().expect("valid"),
main_id,
PreviousValue::MustExistAndMatch(Target::Symbolic("refs/heads/main".try_into().expect("valid"))),
"action: no update will be performed",
)),
),
(
"refs/heads/symbolic",
"refs/remotes/origin/a",
fetch::refs::Update {
mode: fetch::refs::update::Mode::NoChangeNeeded,
type_change: None,
edit_index: Some(0),
},
Some(RefEdit::update(
"refs/remotes/origin/a".try_into().expect("valid"),
main_id,
PreviousValue::MustExistAndMatch(Target::Object(main_id)),
"action: no update will be performed",
)),
),
(
"refs/heads/symbolic",
"refs/heads/symbolic",
fetch::refs::Update {
mode: fetch::refs::update::Mode::NoChangeNeeded,
type_change: Some(TypeChange::SymbolicToDirect),
edit_index: Some(0),
},
Some(RefEdit::update(
"refs/heads/symbolic".try_into().expect("valid"),
main_id,
PreviousValue::MustExistAndMatch(Target::Symbolic("refs/heads/main".try_into().expect("valid"))),
"action: no update will be performed",
)),
),
] {
let (mappings, specs) = mapping_from_spec(&format!("{source}:{destination}"), &repo);
assert_eq!(mappings.len(), 1);
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)
.unwrap();
assert_eq!(
out.edits.len(),
usize::from(expected_edit.is_some()),
"{source}:{destination}"
);
assert_eq!(out.updates, vec![expected_update], "{source}:{destination}");
if let Some(expected) = expected_edit {
assert_eq!(out.edits, vec![expected], "{source}:{destination}");
}
}
}
#[test]
fn remote_symbolic_refs_can_always_be_set_as_there_is_no_scenario_where_it_could_be_nonexisting_and_rejected() {
let repo = repo("two-origins");
let (mut mappings, specs) = mapping_from_spec("refs/heads/symbolic:refs/remotes/origin/new", &repo);
mappings.push(Mapping {
remote: Source::Ref(gix_protocol::handshake::Ref::Direct {
full_ref_name: "refs/heads/main".into(),
object: peeled_id(&repo, "refs/heads/main"),
}),
local: Some("refs/heads/symbolic".into()),
spec_index: SpecIndex::ExplicitInRemote(0),
});
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)
.unwrap();
assert_eq!(out.edits.len(), 2, "symbolic refs are handled just like any other ref");
assert_eq!(
out.updates,
vec![
fetch::refs::Update {
mode: fetch::refs::update::Mode::New,
type_change: None,
edit_index: Some(0)
},
fetch::refs::Update {
mode: fetch::refs::update::Mode::NoChangeNeeded,
type_change: Some(TypeChange::SymbolicToDirect),
edit_index: Some(1)
}
],
);
let edit = &out.edits[0];
match &edit.change {
Change::Update { log, new, .. } => {
assert_eq!(log.message, "action: storing ref");
let target = peeled_id(&repo, "refs/heads/main");
assert_eq!(
new.try_id(),
Some(target.as_ref()),
"git writes fetched born remote symrefs as direct refs, even when the symref target is also mapped"
);
}
_ => unreachable!("only updates"),
}
}
#[test]
fn local_direct_refs_are_written_with_symbolic_ones() {
let repo = repo("two-origins");
let (mappings, specs) = mapping_from_spec("refs/heads/symbolic:refs/heads/not-currently-checked-out", &repo);
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)
.unwrap();
assert_eq!(out.edits.len(), 1);
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::NoChangeNeeded,
type_change: None,
edit_index: Some(0)
}],
concat!(
"the remote symref target is not mapped, so new_value_by_remote() stores the advertised object id ",
"instead of a symbolic target; the local destination is already direct, so the update is direct-to-direct ",
"and has no type_change"
)
);
}
#[test]
fn remote_refs_cannot_map_to_local_head() {
let repo = repo("two-origins");
let (mappings, specs) = mapping_from_spec("refs/heads/main:HEAD", &repo);
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)
.unwrap();
assert_eq!(out.edits.len(), 1);
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::New,
type_change: None,
edit_index: Some(0),
}],
);
let edit = &out.edits[0];
match &edit.change {
Change::Update { log, new, .. } => {
assert_eq!(log.message, "action: storing head");
assert!(
new.try_id().is_some(),
"remote is peeled, so local will be peeled as well"
);
}
_ => unreachable!("only updates"),
}
assert_eq!(
edit.name, "refs/heads/HEAD",
"it's not possible to refer to the local HEAD with refspecs"
);
}
#[test]
fn remote_symbolic_refs_are_peeled_even_if_their_target_is_mapped() {
let repo = repo("two-origins");
let (mut mappings, specs) = mapping_from_spec("HEAD:refs/remotes/origin/new-HEAD", &repo);
mappings.push(Mapping {
remote: Source::Ref(gix_protocol::handshake::Ref::Direct {
full_ref_name: "refs/heads/main".into(),
object: peeled_id(&repo, "refs/heads/main"),
}),
local: Some("refs/remotes/origin/main".into()),
spec_index: SpecIndex::ExplicitInRemote(0),
});
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)
.unwrap();
assert_eq!(
out.updates,
vec![
fetch::refs::Update {
mode: fetch::refs::update::Mode::New,
type_change: None,
edit_index: Some(0),
},
fetch::refs::Update {
mode: fetch::refs::update::Mode::NoChangeNeeded,
type_change: None,
edit_index: Some(1),
}
],
);
assert_eq!(out.edits.len(), 2);
let edit = &out.edits[0];
match &edit.change {
Change::Update { log, new, .. } => {
assert_eq!(log.message, "action: storing ref");
let target = peeled_id(&repo, "refs/heads/main");
assert_eq!(
new.try_id(),
Some(target.as_ref()),
"git writes fetched born remote symrefs as direct refs, even when the symref target is also mapped"
);
}
_ => unreachable!("only updates"),
}
assert_eq!(edit.name, "refs/remotes/origin/new-HEAD");
}
#[test]
fn non_fast_forward_is_rejected_but_appears_to_be_fast_forward_in_dryrun_mode() {
let repo = repo("two-origins");
let (mappings, specs) = mapping_from_spec("refs/heads/main:refs/remotes/origin/g", &repo);
let reflog_message: BString = "very special".into();
let out = fetch::refs::update(
&repo,
RefLogMessage::Override {
message: reflog_message.clone(),
},
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::Yes,
fetch::WritePackedRefs::Never,
)
.unwrap();
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::FastForward,
type_change: None,
edit_index: Some(0),
}],
"The caller has to be aware and note that dry-runs can't know about fast-forwards as they don't have remote objects"
);
assert_eq!(out.edits.len(), 1);
let edit = &out.edits[0];
match &edit.change {
Change::Update { log, .. } => {
assert_eq!(log.message, reflog_message);
}
_ => unreachable!("only updates"),
}
}
#[test]
fn non_fast_forward_is_rejected_if_dry_run_is_disabled() {
let (repo, _tmp) = repo_rw("two-origins");
let (mappings, specs) = mapping_from_spec("refs/remotes/origin/g:refs/heads/not-currently-checked-out", &repo);
let out = fetch::refs::update(
&repo,
prefixed("action"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::No,
fetch::WritePackedRefs::Never,
)
.unwrap();
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::RejectedNonFastForward,
type_change: None,
edit_index: None,
}]
);
assert_eq!(out.edits.len(), 0);
let (mappings, specs) = mapping_from_spec("refs/heads/main:refs/remotes/origin/g", &repo);
let out = fetch::refs::update(
&repo,
prefixed("prefix"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::No,
fetch::WritePackedRefs::Never,
)
.unwrap();
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::FastForward,
type_change: None,
edit_index: Some(0),
}]
);
assert_eq!(out.edits.len(), 1);
let edit = &out.edits[0];
match &edit.change {
Change::Update { log, .. } => {
assert_eq!(log.message, format!("prefix: {}", "fast-forward"));
}
_ => unreachable!("only updates"),
}
}
#[test]
fn malformed_commits_cannot_force_reference_updates() -> TestResult {
let mut diagnostics = Vec::new();
use gix_object::Write;
let (repo, _tmp) = repo_rw("two-origins");
let malformed_commit_id = repo.objects.write_buf(gix_object::Kind::Commit, b"malformed commit")?;
let commit_id = repo.head_id()?;
let name = "refs/remotes/origin/broken";
for (local_id, remote_id) in [
(malformed_commit_id, commit_id.into()),
(commit_id.into(), malformed_commit_id),
] {
repo.reference(name, local_id, PreviousValue::Any, "install local target")?;
let (mappings, specs) = mapping_from_spec(&format!("{remote_id}:{name}"), &repo);
let err = fetch::refs::update(
&repo,
prefixed("fetch"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::No,
fetch::WritePackedRefs::Never,
)
.expect_err("a failed ancestry check must not authorize a forced update");
diagnostics.push(gix_testtools::redact_debug_snapshot(&err, &[]));
assert!(err.is_validation(), "the commit parser's cause survives");
assert_eq!(
repo.find_reference(name)?.id(),
local_id,
"the failed check cannot change the ref"
);
}
insta::assert_debug_snapshot!(diagnostics, "malformed commits cannot force reference updates", @"
[
object parsing failed,
A commit could not be decoded during traversal
Caused by:
0: object parsing failed,
]
");
Ok(())
}
#[test]
fn non_commit_targets_can_still_be_updated() -> TestResult {
let (repo, _tmp) = repo_rw("two-origins");
let blob_id = repo.write_blob(b"valid blob")?;
let commit_id = repo.head_id()?;
let name = "refs/remotes/origin/non-commit";
for (local_id, remote_id) in [(blob_id, commit_id), (commit_id, blob_id)] {
repo.reference(name, local_id, PreviousValue::Any, "install local target")?;
let (mappings, specs) = mapping_from_spec(&format!("{remote_id}:{name}"), &repo);
let out = fetch::refs::update(
&repo,
prefixed("fetch"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::No,
fetch::WritePackedRefs::Never,
)?;
assert_eq!(out.updates[0].mode, fetch::refs::update::Mode::Forced);
assert_eq!(repo.find_reference(name)?.id(), remote_id);
}
Ok(())
}
#[test]
fn fast_forwards_are_called_out_even_if_force_is_given() {
let (repo, _tmp) = repo_rw("two-origins");
let (mappings, specs) = mapping_from_spec("+refs/heads/main:refs/remotes/origin/g", &repo);
let out = fetch::refs::update(
&repo,
prefixed("prefix"),
&mappings,
&specs,
&[],
fetch::Tags::None,
fetch::DryRun::No,
fetch::WritePackedRefs::Never,
)
.unwrap();
assert_eq!(
out.updates,
vec![fetch::refs::Update {
mode: fetch::refs::update::Mode::FastForward,
type_change: None,
edit_index: Some(0),
}]
);
assert_eq!(out.edits.len(), 1);
let edit = &out.edits[0];
match &edit.change {
Change::Update { log, .. } => {
assert_eq!(log.message, format!("prefix: {}", "fast-forward"));
}
_ => unreachable!("only updates"),
}
}
fn mapping_from_spec(
spec: &str,
remote_repo: &gix::Repository,
) -> (Vec<fetch::refmap::Mapping>, Vec<gix::refspec::RefSpec>) {
let spec = gix_refspec::parse(spec.into(), gix_refspec::parse::Operation::Fetch).unwrap();
let group = gix_refspec::MatchGroup::from_fetch_specs(Some(spec));
let references = remote_repo.references().unwrap();
let mut references: Vec<_> = references.all().unwrap().map(|r| into_remote_ref(r.unwrap())).collect();
references.push(into_remote_ref(remote_repo.find_reference("HEAD").unwrap()));
let null = remote_repo.object_hash().null();
let mappings = group
.match_lhs(references.iter().map(|r| remote_ref_to_item(r, &null)))
.mappings
.into_iter()
.map(|m| fetch::refmap::Mapping {
remote: m.item_index.map_or_else(
|| match m.lhs {
gix_refspec::match_group::SourceRef::ObjectId(id) => fetch::refmap::Source::ObjectId(id),
_ => unreachable!("not a ref, must be id: {:?}", m),
},
|idx| fetch::refmap::Source::Ref(references[idx].clone()),
),
local: m.rhs.map(std::borrow::Cow::into_owned),
spec_index: SpecIndex::ExplicitInRemote(m.spec_index),
})
.collect();
(mappings, vec![spec.to_owned()])
}
fn into_remote_ref(mut r: gix::Reference<'_>) -> gix_protocol::handshake::Ref {
let full_ref_name = r.name().as_bstr().into();
match r.target() {
TargetRef::Object(id) => gix_protocol::handshake::Ref::Direct {
full_ref_name,
object: id.into(),
},
TargetRef::Symbolic(name) => {
let target = name.as_bstr().into();
match r.peel_to_id() {
Ok(id) => gix_protocol::handshake::Ref::Symbolic {
full_ref_name,
target,
tag: None,
object: id.detach(),
},
Err(_) => gix_protocol::handshake::Ref::Unborn { full_ref_name, target },
}
}
}
}
fn remote_ref_to_item<'a>(
r: &'a gix_protocol::handshake::Ref,
null: &'a gix_hash::oid,
) -> gix_refspec::match_group::Item<'a> {
let (full_ref_name, target, object) = r.unpack();
gix_refspec::match_group::Item {
full_ref_name,
target: target.unwrap_or(null),
object,
}
}
fn prefixed(action: &str) -> RefLogMessage {
RefLogMessage::Prefixed { action: action.into() }
}
}