Skip to content

Commit 74ce863

Browse files
committed
fix!: handle symbolic ref updates far more gracefully and with more logical consistency.
Previously, refspecs couldn't be used to update sybolic references locally, particularly because the logic to do so correctly isn't trivial and `git` itself also seems to cover only the most common cases. However, the logic now changed so that remote updates will only be rejected if * fast-forward rules are violated * the local ref is currently checked out * existing refs would not become 'unborn', i.e. point to a reference that doesn't exist and won't be created due to ref-specs This makes it possible to update unborn remote refs to local refs if these are new or unborn themselves. This also allows to create mirrors more easily and allows us to handle `HEAD` without special casing it. Bare repositories have an easier time to update local symbolic refs.
1 parent df81076 commit 74ce863

File tree

8 files changed

+790
-127
lines changed

8 files changed

+790
-127
lines changed

gix/src/reference/log.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ impl<'repo> Reference<'repo> {
1212
pub fn log_iter(&self) -> gix_ref::file::log::iter::Platform<'_, '_> {
1313
self.inner.log_iter(&self.repo.refs)
1414
}
15+
16+
/// Return true if a reflog is present for this reference.
17+
pub fn log_exists(&self) -> bool {
18+
self.inner.log_exists(&self.repo.refs)
19+
}
1520
}
1621

1722
/// Generate a message typical for git commit logs based on the given `operation`, commit `message` and `num_parents` of the commit.

gix/src/remote/connection/fetch/update_refs/mod.rs

Lines changed: 264 additions & 80 deletions
Large diffs are not rendered by default.

gix/src/remote/connection/fetch/update_refs/tests.rs

Lines changed: 401 additions & 23 deletions
Large diffs are not rendered by default.

gix/src/remote/connection/fetch/update_refs/update.rs

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ mod error {
1919
OpenWorktreeRepo(#[from] crate::open::Error),
2020
#[error("Could not find local commit for fast-forward ancestor check")]
2121
FindCommit(#[from] crate::object::find::existing::Error),
22+
#[error("Could not peel symbolic local reference to its ID")]
23+
PeelToId(#[from] crate::reference::peel::Error),
24+
#[error("Failed to follow a symbolic reference to assure worktree isn't affected")]
25+
FollowSymref(#[from] gix_ref::file::find::existing::Error),
2226
}
2327
}
2428

@@ -35,11 +39,14 @@ pub struct Outcome {
3539
pub updates: Vec<super::Update>,
3640
}
3741

38-
/// Describe the way a ref was updated
42+
/// Describe the way a ref was updated, with particular focus on how the (peeled) target commit was affected.
43+
///
44+
/// Note that for all the variants that signal a change or `NoChangeNeeded` it's additionally possible to change the target type
45+
/// from symbolic to direct, or the other way around.
3946
#[derive(Debug, Clone, PartialEq, Eq)]
4047
pub enum Mode {
4148
/// No change was attempted as the remote ref didn't change compared to the current ref, or because no remote ref was specified
42-
/// in the ref-spec.
49+
/// in the ref-spec. Note that the expected value is still asserted to uncover potential race conditions with other processes.
4350
NoChangeNeeded,
4451
/// The old ref's commit was an ancestor of the new one, allowing for a fast-forward without a merge.
4552
FastForward,
@@ -62,14 +69,19 @@ pub enum Mode {
6269
RejectedTagUpdate,
6370
/// The reference update would not have been a fast-forward, and force is not specified in the ref-spec.
6471
RejectedNonFastForward,
65-
/// The update of a local symbolic reference was rejected.
66-
RejectedSymbolic,
72+
/// The remote has an unborn symbolic reference where we have one that is set. This means the remote
73+
/// has reset itself to a newly initialized state or a state that is highly unusual.
74+
/// It may also mean that the remote knows the target name, but it's not available locally and not included in the ref-mappings
75+
/// to be created, so we would effectively change a valid local ref into one that seems unborn, which is rejected.
76+
/// Note that this mode may have an associated ref-edit that is a no-op, or current-state assertion, for logistical reasons only
77+
/// and having no edit would be preferred.
78+
RejectedToReplaceWithUnborn,
6779
/// The update was rejected because the branch is checked out in the given worktree_dir.
6880
///
6981
/// Note that the check applies to any known worktree, whether it's present on disk or not.
7082
RejectedCurrentlyCheckedOut {
71-
/// The path to the worktree directory where the branch is checked out.
72-
worktree_dir: PathBuf,
83+
/// The path(s) to the worktree directory where the branch is checked out.
84+
worktree_dirs: Vec<PathBuf>,
7385
},
7486
}
7587

@@ -84,19 +96,32 @@ impl std::fmt::Display for Mode {
8496
Mode::RejectedSourceObjectNotFound { id } => return write!(f, "rejected ({id} not found)"),
8597
Mode::RejectedTagUpdate => "rejected (would overwrite existing tag)",
8698
Mode::RejectedNonFastForward => "rejected (non-fast-forward)",
87-
Mode::RejectedSymbolic => "rejected (refusing to write symbolic refs)",
88-
Mode::RejectedCurrentlyCheckedOut { worktree_dir } => {
99+
Mode::RejectedToReplaceWithUnborn => "rejected (refusing to overwrite existing with unborn ref)",
100+
Mode::RejectedCurrentlyCheckedOut { worktree_dirs } => {
89101
return write!(
90102
f,
91103
"rejected (cannot write into checked-out branch at \"{}\")",
92-
worktree_dir.display()
104+
worktree_dirs
105+
.iter()
106+
.filter_map(|d| d.to_str())
107+
.collect::<Vec<_>>()
108+
.join(", ")
93109
)
94110
}
95111
}
96112
.fmt(f)
97113
}
98114
}
99115

116+
/// Indicates that a ref changes its type.
117+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash)]
118+
pub enum TypeChange {
119+
/// A local direct reference is changed into a symbolic one.
120+
DirectToSymbolic,
121+
/// A local symbolic reference is changed into a direct one.
122+
SymbolicToDirect,
123+
}
124+
100125
impl Outcome {
101126
/// Produce an iterator over all information used to produce the this outcome, ref-update by ref-update, using the `mappings`
102127
/// used when producing the ref update.

gix/src/remote/fetch.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,18 @@ impl Source {
154154
}
155155
}
156156

157+
/// Return the target that this symbolic ref is pointing to, or `None` if it is no symbolic ref.
158+
pub fn as_target(&self) -> Option<&crate::bstr::BStr> {
159+
match self {
160+
Source::ObjectId(_) => None,
161+
Source::Ref(r) => match r {
162+
gix_protocol::handshake::Ref::Peeled { .. } | gix_protocol::handshake::Ref::Direct { .. } => None,
163+
gix_protocol::handshake::Ref::Symbolic { target, .. }
164+
| gix_protocol::handshake::Ref::Unborn { target, .. } => Some(target.as_ref()),
165+
},
166+
}
167+
}
168+
157169
/// Returns the peeled id of this instance, that is the object that can't be de-referenced anymore.
158170
pub fn peeled_id(&self) -> Option<&gix_hash::oid> {
159171
match self {

gix/tests/clone/mod.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,8 +348,22 @@ mod blocking_io {
348348
.expect("computable")
349349
.1
350350
.starts_with_str(remote_name));
351-
let mut logs = r.log_iter();
352-
assert_reflog(logs.all());
351+
match r.target() {
352+
TargetRef::Peeled(_) => {
353+
let mut logs = r.log_iter();
354+
assert_reflog(logs.all());
355+
}
356+
TargetRef::Symbolic(_) => {
357+
// TODO: it *should* be possible to set the reflog here based on the referent if deref = true
358+
// when setting up the edits. But it doesn't seem to work. Also, some tests are
359+
// missing for `leaf_referent_previous_oid`.
360+
assert!(
361+
!r.log_exists(),
362+
"symbolic refs don't have object ids, so they can't get \
363+
into the reflog as these need previous and new oid"
364+
)
365+
}
366+
}
353367
}
354368
}
355369
let mut out_of_graph_tags = Vec::new();

gix/tests/fixtures/make_remote_repos.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,22 @@ function optimize_repo() {
350350
optimize_repo
351351
)
352352
)
353+
354+
git init unborn
355+
(cd unborn
356+
git symbolic-ref refs/heads/existing-unborn-symbolic refs/heads/main
357+
git symbolic-ref refs/heads/existing-unborn-symbolic-other refs/heads/other
358+
)
359+
360+
git init one-commit-with-symref
361+
(cd one-commit-with-symref
362+
touch content && git add content && git commit -m "init"
363+
git checkout -b branch
364+
git symbolic-ref refs/heads/symbolic refs/heads/branch
365+
git checkout main
366+
)
367+
368+
git clone one-commit-with-symref one-commit-with-symref-missing-branch
369+
(cd one-commit-with-symref-missing-branch
370+
git branch valid-locally
371+
)

gix/tests/remote/fetch.rs

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -559,10 +559,23 @@ mod blocking_and_async_io {
559559
);
560560
let edit = &update_refs.edits[1];
561561
assert_eq!(edit.name.as_bstr(), "refs/remotes/changes-on-top-of-origin/symbolic");
562-
assert!(
563-
edit.change.new_value().expect("no deletion").try_id().is_some(),
564-
"on the remote this is a symbolic ref, we just write its destination object id though"
565-
);
562+
match version.unwrap_or_default() {
563+
gix::protocol::transport::Protocol::V2 => {
564+
assert!(
565+
edit.change.new_value().expect("no deletion").try_name().is_some(),
566+
"{version:?} on the remote this is a symbolic ref, and we maintain it as symref information is kept"
567+
);
568+
}
569+
gix::protocol::transport::Protocol::V1 => {
570+
assert!(
571+
edit.change.new_value().expect("no deletion").try_id().is_some(),
572+
"on the remote this is a symbolic ref, but in V1 symrefs are not visible"
573+
);
574+
}
575+
gix::protocol::transport::Protocol::V0 => {
576+
unreachable!("we don't test this here as there is no need")
577+
}
578+
}
566579

567580
assert!(
568581
!write_pack_bundle.keep_path.map_or(false, |f| f.is_file()),
@@ -589,10 +602,12 @@ mod blocking_and_async_io {
589602
vec![
590603
fetch::refs::Update {
591604
mode: fetch::refs::update::Mode::New,
605+
type_change: None,
592606
edit_index: Some(0),
593607
},
594608
fetch::refs::Update {
595609
mode: fetch::refs::update::Mode::New,
610+
type_change: None,
596611
edit_index: Some(1),
597612
}
598613
]
@@ -604,21 +619,32 @@ mod blocking_and_async_io {
604619
) {
605620
let edit = edit.expect("refedit present even if it's a no-op");
606621
if dry_run {
607-
assert_eq!(
608-
edit.change.new_value().expect("no deletions").id(),
609-
mapping.remote.as_id().expect("no unborn")
610-
);
622+
match edit.change.new_value().expect("no deletions") {
623+
gix_ref::TargetRef::Peeled(id) => {
624+
assert_eq!(id, mapping.remote.as_id().expect("no unborn"))
625+
}
626+
gix_ref::TargetRef::Symbolic(target) => {
627+
assert_eq!(target.as_bstr(), mapping.remote.as_target().expect("no direct ref"))
628+
}
629+
}
611630
assert!(
612631
repo.try_find_reference(edit.name.as_ref())?.is_none(),
613632
"no ref created in dry-run mode"
614633
);
615634
} else {
616635
let r = repo.find_reference(edit.name.as_ref()).unwrap();
617-
assert_eq!(
618-
r.id(),
619-
*mapping.remote.as_id().expect("no unborn"),
620-
"local reference should point to remote id"
621-
);
636+
match r.target() {
637+
gix_ref::TargetRef::Peeled(id) => {
638+
assert_eq!(
639+
id,
640+
mapping.remote.as_id().expect("no unborn"),
641+
"local reference should point to remote id"
642+
);
643+
}
644+
gix_ref::TargetRef::Symbolic(target) => {
645+
assert_eq!(target.as_bstr(), mapping.remote.as_target().expect("no direct ref"))
646+
}
647+
}
622648
}
623649
}
624650
}

0 commit comments

Comments
 (0)