just playing with tangled
1// Copyright 2020 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#![allow(missing_docs)]
16
17use std::collections::{BTreeMap, HashMap, HashSet};
18use std::default::Default;
19use std::io::Read;
20use std::path::PathBuf;
21
22use git2::Oid;
23use itertools::Itertools;
24use tempfile::NamedTempFile;
25use thiserror::Error;
26
27use crate::backend::{CommitId, ObjectId};
28use crate::git_backend::NO_GC_REF_NAMESPACE;
29use crate::op_store::{BranchTarget, RefTarget, RefTargetOptionExt};
30use crate::repo::{MutableRepo, Repo};
31use crate::revset;
32use crate::settings::GitSettings;
33use crate::view::{RefName, View};
34
35#[derive(Error, Debug, PartialEq)]
36pub enum GitImportError {
37 #[error("Unexpected git error when importing refs: {0}")]
38 InternalGitError(#[from] git2::Error),
39}
40
41fn parse_git_ref(ref_name: &str) -> Option<RefName> {
42 if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") {
43 // Git CLI says 'HEAD' is not a valid branch name
44 (branch_name != "HEAD").then(|| RefName::LocalBranch(branch_name.to_string()))
45 } else if let Some(remote_and_branch) = ref_name.strip_prefix("refs/remotes/") {
46 remote_and_branch
47 .split_once('/')
48 // "refs/remotes/origin/HEAD" isn't a real remote-tracking branch
49 .filter(|&(_, branch)| branch != "HEAD")
50 .map(|(remote, branch)| RefName::RemoteBranch {
51 remote: remote.to_string(),
52 branch: branch.to_string(),
53 })
54 } else {
55 ref_name
56 .strip_prefix("refs/tags/")
57 .map(|tag_name| RefName::Tag(tag_name.to_string()))
58 }
59}
60
61fn to_git_ref_name(parsed_ref: &RefName) -> Option<String> {
62 match parsed_ref {
63 RefName::LocalBranch(branch) => (branch != "HEAD").then(|| format!("refs/heads/{branch}")),
64 RefName::RemoteBranch { branch, remote } => {
65 (branch != "HEAD").then(|| format!("refs/remotes/{remote}/{branch}"))
66 }
67 RefName::Tag(tag) => Some(format!("refs/tags/{tag}")),
68 RefName::GitRef(name) => Some(name.to_owned()),
69 }
70}
71
72fn to_remote_branch<'a>(parsed_ref: &'a RefName, remote_name: &str) -> Option<&'a str> {
73 match parsed_ref {
74 RefName::RemoteBranch { branch, remote } => (remote == remote_name).then_some(branch),
75 RefName::LocalBranch(..) | RefName::Tag(..) | RefName::GitRef(..) => None,
76 }
77}
78
79/// Checks if `git_ref` points to a Git commit object, and returns its id.
80///
81/// If the ref points to the previously `known_target` (i.e. unchanged), this
82/// should be faster than `git_ref.peel_to_commit()`.
83fn resolve_git_ref_to_commit_id(
84 git_ref: &git2::Reference<'_>,
85 known_target: &RefTarget,
86) -> Option<CommitId> {
87 // Try fast path if we have a candidate id which is known to be a commit object.
88 if let Some(id) = known_target.as_normal() {
89 if matches!(git_ref.target(), Some(oid) if oid.as_bytes() == id.as_bytes()) {
90 return Some(id.clone());
91 }
92 if matches!(git_ref.target_peel(), Some(oid) if oid.as_bytes() == id.as_bytes()) {
93 // Perhaps an annotated tag stored in packed-refs file, and pointing to the
94 // already known target commit.
95 return Some(id.clone());
96 }
97 // A tag (according to ref name.) Try to peel one more level. This is slightly
98 // faster than recurse into peel_to_commit(). If we recorded a tag oid, we
99 // could skip this at all.
100 if let Some(Ok(tag)) = git_ref.is_tag().then(|| git_ref.peel_to_tag()) {
101 if tag.target_id().as_bytes() == id.as_bytes() {
102 // An annotated tag pointing to the already known target commit.
103 return Some(id.clone());
104 } else {
105 // Unknown id. Recurse from the current state as git_object_peel() of
106 // libgit2 would do. A tag may point to non-commit object.
107 let git_commit = tag.into_object().peel_to_commit().ok()?;
108 return Some(CommitId::from_bytes(git_commit.id().as_bytes()));
109 }
110 }
111 }
112
113 let git_commit = git_ref.peel_to_commit().ok()?;
114 Some(CommitId::from_bytes(git_commit.id().as_bytes()))
115}
116
117/// Builds a map of branches which also includes pseudo `@git` remote.
118///
119/// If there's an existing remote named `git`, a list of conflicting branch
120/// names will be returned.
121pub fn build_unified_branches_map(view: &View) -> (BTreeMap<String, BranchTarget>, Vec<String>) {
122 let mut all_branches = view.branches().clone();
123 let mut bad_branch_names = Vec::new();
124 for (branch_name, git_tracking_target) in local_branch_git_tracking_refs(view) {
125 let branch_target = all_branches.entry(branch_name.to_owned()).or_default();
126 if branch_target.remote_targets.contains_key("git") {
127 // TODO(#1690): There should be a mechanism to prevent importing a
128 // remote named "git" in `jj git import`.
129 bad_branch_names.push(branch_name.to_owned());
130 } else {
131 // TODO: `BTreeMap::try_insert` could be used here once that's stabilized.
132 branch_target
133 .remote_targets
134 .insert("git".to_owned(), git_tracking_target.clone());
135 }
136 }
137 (all_branches, bad_branch_names)
138}
139
140fn local_branch_git_tracking_refs(view: &View) -> impl Iterator<Item = (&str, &RefTarget)> {
141 view.git_refs().iter().filter_map(|(ref_name, target)| {
142 ref_name
143 .strip_prefix("refs/heads/")
144 .map(|branch_name| (branch_name, target))
145 })
146}
147
148pub fn get_local_git_tracking_branch<'a>(view: &'a View, branch: &str) -> &'a RefTarget {
149 view.get_git_ref(&format!("refs/heads/{branch}"))
150}
151
152fn prevent_gc(git_repo: &git2::Repository, id: &CommitId) -> Result<(), git2::Error> {
153 // If multiple processes do git::import_refs() in parallel, this can fail to
154 // acquire a lock file even with force=true.
155 git_repo.reference(
156 &format!("{}{}", NO_GC_REF_NAMESPACE, id.hex()),
157 Oid::from_bytes(id.as_bytes()).unwrap(),
158 true,
159 "used by jj",
160 )?;
161 Ok(())
162}
163
164/// Reflect changes made in the underlying Git repo in the Jujutsu repo.
165///
166/// This function detects conflicts (if both Git and JJ modified a branch) and
167/// records them in JJ's view.
168pub fn import_refs(
169 mut_repo: &mut MutableRepo,
170 git_repo: &git2::Repository,
171 git_settings: &GitSettings,
172) -> Result<(), GitImportError> {
173 import_some_refs(mut_repo, git_repo, git_settings, |_| true)
174}
175
176/// Reflect changes made in the underlying Git repo in the Jujutsu repo.
177///
178/// Only branches whose git full reference name pass the filter will be
179/// considered for addition, update, or deletion.
180pub fn import_some_refs(
181 mut_repo: &mut MutableRepo,
182 git_repo: &git2::Repository,
183 git_settings: &GitSettings,
184 git_ref_filter: impl Fn(&RefName) -> bool,
185) -> Result<(), GitImportError> {
186 let store = mut_repo.store().clone();
187 let mut jj_view_git_refs = mut_repo.view().git_refs().clone();
188 let mut pinned_git_heads = HashMap::new();
189
190 // TODO: Should this be a separate function? We may not always want to import
191 // the Git HEAD (and add it to our set of heads).
192 if let Ok(head_git_commit) = git_repo
193 .head()
194 .and_then(|head_ref| head_ref.peel_to_commit())
195 {
196 // Add the current HEAD to `pinned_git_heads` to pin the branch. It's not added
197 // to `hidable_git_heads` because HEAD move doesn't automatically mean the old
198 // HEAD branch has been rewritten.
199 let head_ref_name = RefName::GitRef("HEAD".to_owned());
200 let head_commit_id = CommitId::from_bytes(head_git_commit.id().as_bytes());
201 pinned_git_heads.insert(head_ref_name, vec![head_commit_id.clone()]);
202 if !matches!(mut_repo.git_head().as_normal(), Some(id) if id == &head_commit_id) {
203 let head_commit = store.get_commit(&head_commit_id).unwrap();
204 prevent_gc(git_repo, &head_commit_id)?;
205 mut_repo.add_head(&head_commit);
206 mut_repo.set_git_head_target(RefTarget::normal(head_commit_id));
207 }
208 } else {
209 mut_repo.set_git_head_target(RefTarget::absent());
210 }
211
212 let mut changed_git_refs = BTreeMap::new();
213 let git_repo_refs = git_repo.references()?;
214 for git_repo_ref in git_repo_refs {
215 let git_repo_ref = git_repo_ref?;
216 let Some(full_name) = git_repo_ref.name() else {
217 // Skip non-utf8 refs.
218 continue;
219 };
220 let Some(ref_name) = parse_git_ref(full_name) else {
221 // Skip other refs (such as notes) and symbolic refs.
222 continue;
223 };
224 let Some(id) =
225 resolve_git_ref_to_commit_id(&git_repo_ref, jj_view_git_refs.get(full_name).flatten())
226 else {
227 // Skip invalid refs.
228 continue;
229 };
230 pinned_git_heads.insert(ref_name.clone(), vec![id.clone()]);
231 if !git_ref_filter(&ref_name) {
232 continue;
233 }
234 // TODO: Make it configurable which remotes are publishing and update public
235 // heads here.
236 let old_target = jj_view_git_refs.remove(full_name).flatten();
237 let new_target = RefTarget::normal(id.clone());
238 if new_target != old_target {
239 prevent_gc(git_repo, &id)?;
240 mut_repo.set_git_ref_target(full_name, RefTarget::normal(id.clone()));
241 let commit = store.get_commit(&id).unwrap();
242 mut_repo.add_head(&commit);
243 changed_git_refs.insert(ref_name, (old_target, new_target));
244 }
245 }
246 for (full_name, target) in jj_view_git_refs {
247 // TODO: or clean up invalid ref in case it was stored due to historical bug?
248 let ref_name = parse_git_ref(&full_name).expect("stored git ref should be parsable");
249 if git_ref_filter(&ref_name) {
250 mut_repo.set_git_ref_target(&full_name, RefTarget::absent());
251 changed_git_refs.insert(ref_name, (target, RefTarget::absent()));
252 } else {
253 pinned_git_heads.insert(ref_name, target.added_ids().cloned().collect());
254 }
255 }
256 for (ref_name, (old_git_target, new_git_target)) in &changed_git_refs {
257 // Apply the change that happened in git since last time we imported refs
258 mut_repo.merge_single_ref(ref_name, old_git_target, new_git_target);
259 // If a git remote-tracking branch changed, apply the change to the local branch
260 // as well
261 if !git_settings.auto_local_branch {
262 continue;
263 }
264 if let RefName::RemoteBranch { branch, remote: _ } = ref_name {
265 let local_ref_name = RefName::LocalBranch(branch.clone());
266 mut_repo.merge_single_ref(&local_ref_name, old_git_target, new_git_target);
267 let target = mut_repo.get_local_branch(branch);
268 if target.is_absent() {
269 pinned_git_heads.remove(&local_ref_name);
270 } else {
271 // Note that we are mostly *replacing*, not inserting
272 pinned_git_heads.insert(local_ref_name, target.added_ids().cloned().collect());
273 }
274 }
275 }
276
277 // Find commits that are no longer referenced in the git repo and abandon them
278 // in jj as well.
279 let hidable_git_heads = changed_git_refs
280 .values()
281 .flat_map(|(old_git_target, _)| old_git_target.added_ids())
282 .cloned()
283 .collect_vec();
284 if hidable_git_heads.is_empty() {
285 return Ok(());
286 }
287 // We must remove non-existing commits from pinned_git_heads, as they could have
288 // come from branches which were never fetched.
289 let mut pinned_git_heads_set = HashSet::new();
290 for heads_for_ref in pinned_git_heads.into_values() {
291 pinned_git_heads_set.extend(heads_for_ref);
292 }
293 pinned_git_heads_set.retain(|id| mut_repo.index().has_id(id));
294 // We could use mut_repo.record_rewrites() here but we know we only need to care
295 // about abandoned commits for now. We may want to change this if we ever
296 // add a way of preserving change IDs across rewrites by `git` (e.g. by
297 // putting them in the commit message).
298 let abandoned_commits = revset::walk_revs(
299 mut_repo,
300 &hidable_git_heads,
301 &pinned_git_heads_set.into_iter().collect_vec(),
302 )
303 .unwrap()
304 .iter()
305 .collect_vec();
306 let root_commit_id = mut_repo.store().root_commit_id().clone();
307 for abandoned_commit in abandoned_commits {
308 if abandoned_commit != root_commit_id {
309 mut_repo.record_abandoned_commit(abandoned_commit);
310 }
311 }
312
313 Ok(())
314}
315
316#[derive(Error, Debug, PartialEq)]
317pub enum GitExportError {
318 #[error("Cannot export conflicted branch '{0}'")]
319 ConflictedBranch(String),
320 #[error("Failed to read export state: {0}")]
321 ReadStateError(String),
322 #[error("Failed to write export state: {0}")]
323 WriteStateError(String),
324 #[error("Git error: {0}")]
325 InternalGitError(#[from] git2::Error),
326}
327
328/// Export changes to branches made in the Jujutsu repo compared to our last
329/// seen view of the Git repo in `mut_repo.view().git_refs()`. Returns a list of
330/// refs that failed to export.
331///
332/// We ignore changed branches that are conflicted (were also changed in the Git
333/// repo compared to our last remembered view of the Git repo). These will be
334/// marked conflicted by the next `jj git import`.
335///
336/// We do not export tags and other refs at the moment, since these aren't
337/// supposed to be modified by JJ. For them, the Git state is considered
338/// authoritative.
339// TODO: Also indicate why we failed to export these branches
340pub fn export_refs(
341 mut_repo: &mut MutableRepo,
342 git_repo: &git2::Repository,
343) -> Result<Vec<RefName>, GitExportError> {
344 export_some_refs(mut_repo, git_repo, |_| true)
345}
346
347pub fn export_some_refs(
348 mut_repo: &mut MutableRepo,
349 git_repo: &git2::Repository,
350 git_ref_filter: impl Fn(&RefName) -> bool,
351) -> Result<Vec<RefName>, GitExportError> {
352 // First find the changes we want need to make without modifying mut_repo
353 let mut branches_to_update = BTreeMap::new();
354 let mut branches_to_delete = BTreeMap::new();
355 let mut failed_branches = vec![];
356 let view = mut_repo.view();
357 let jj_repo_iter_all_branches = view.branches().iter().flat_map(|(branch, target)| {
358 itertools::chain(
359 target
360 .local_target
361 .is_present()
362 .then(|| RefName::LocalBranch(branch.to_owned())),
363 target
364 .remote_targets
365 .keys()
366 .map(|remote| RefName::RemoteBranch {
367 branch: branch.to_string(),
368 remote: remote.to_string(),
369 }),
370 )
371 });
372 let jj_known_refs_passing_filter: HashSet<_> = view
373 .git_refs()
374 .keys()
375 .filter_map(|name| parse_git_ref(name))
376 .chain(jj_repo_iter_all_branches)
377 .filter(git_ref_filter)
378 .collect();
379 for jj_known_ref in jj_known_refs_passing_filter {
380 let new_branch = match &jj_known_ref {
381 RefName::LocalBranch(branch) => view.get_local_branch(branch),
382 RefName::RemoteBranch { remote, branch } => {
383 // Currently, the only situation where this case occurs *and* new_branch !=
384 // old_branch is after a `jj branch forget`. So, in practice, for
385 // remote-tracking branches either `new_branch == old_branch` or
386 // `new_branch == None`.
387 view.get_remote_branch(branch, remote)
388 }
389 _ => continue,
390 };
391 let old_branch = if let Some(name) = to_git_ref_name(&jj_known_ref) {
392 view.get_git_ref(&name)
393 } else {
394 // Invalid branch name in Git sense
395 failed_branches.push(jj_known_ref);
396 continue;
397 };
398 if new_branch == old_branch {
399 continue;
400 }
401 let old_oid = if let Some(id) = old_branch.as_normal() {
402 Some(Oid::from_bytes(id.as_bytes()).unwrap())
403 } else if old_branch.has_conflict() {
404 // The old git ref should only be a conflict if there were concurrent import
405 // operations while the value changed. Don't overwrite these values.
406 failed_branches.push(jj_known_ref);
407 continue;
408 } else {
409 assert!(old_branch.is_absent());
410 None
411 };
412 if let Some(id) = new_branch.as_normal() {
413 let new_oid = Oid::from_bytes(id.as_bytes());
414 branches_to_update.insert(jj_known_ref, (old_oid, new_oid.unwrap()));
415 } else if new_branch.has_conflict() {
416 // Skip conflicts and leave the old value in git_refs
417 continue;
418 } else {
419 assert!(new_branch.is_absent());
420 branches_to_delete.insert(jj_known_ref, old_oid.unwrap());
421 }
422 }
423 // TODO: Also check other worktrees' HEAD.
424 if let Ok(head_ref) = git_repo.find_reference("HEAD") {
425 if let (Some(head_git_ref), Ok(current_git_commit)) =
426 (head_ref.symbolic_target(), head_ref.peel_to_commit())
427 {
428 if let Some(parsed_ref) = parse_git_ref(head_git_ref) {
429 let detach_head =
430 if let Some((_old_oid, new_oid)) = branches_to_update.get(&parsed_ref) {
431 *new_oid != current_git_commit.id()
432 } else {
433 branches_to_delete.contains_key(&parsed_ref)
434 };
435 if detach_head {
436 git_repo.set_head_detached(current_git_commit.id())?;
437 }
438 }
439 }
440 }
441 for (parsed_ref_name, old_oid) in branches_to_delete {
442 let git_ref_name = to_git_ref_name(&parsed_ref_name).unwrap();
443 let success = if let Ok(mut git_repo_ref) = git_repo.find_reference(&git_ref_name) {
444 if git_repo_ref.target() == Some(old_oid) {
445 // The branch has not been updated by git, so go ahead and delete it
446 git_repo_ref.delete().is_ok()
447 } else {
448 // The branch was updated by git
449 false
450 }
451 } else {
452 // The branch is already deleted
453 true
454 };
455 if success {
456 mut_repo.set_git_ref_target(&git_ref_name, RefTarget::absent());
457 } else {
458 failed_branches.push(parsed_ref_name);
459 }
460 }
461 for (parsed_ref_name, (old_oid, new_oid)) in branches_to_update {
462 let git_ref_name = to_git_ref_name(&parsed_ref_name).unwrap();
463 let success = match old_oid {
464 None => {
465 if let Ok(git_repo_ref) = git_repo.find_reference(&git_ref_name) {
466 // The branch was added in jj and in git. We're good if and only if git
467 // pointed it to our desired target.
468 git_repo_ref.target() == Some(new_oid)
469 } else {
470 // The branch was added in jj but still doesn't exist in git, so add it
471 git_repo
472 .reference(&git_ref_name, new_oid, true, "export from jj")
473 .is_ok()
474 }
475 }
476 Some(old_oid) => {
477 // The branch was modified in jj. We can use libgit2's API for updating under a
478 // lock.
479 if git_repo
480 .reference_matching(&git_ref_name, new_oid, true, old_oid, "export from jj")
481 .is_ok()
482 {
483 // Successfully updated from old_oid to new_oid (unchanged in git)
484 true
485 } else {
486 // The reference was probably updated in git
487 if let Ok(git_repo_ref) = git_repo.find_reference(&git_ref_name) {
488 // We still consider this a success if it was updated to our desired target
489 git_repo_ref.target() == Some(new_oid)
490 } else {
491 // The reference was deleted in git and moved in jj
492 false
493 }
494 }
495 }
496 };
497 if success {
498 mut_repo.set_git_ref_target(
499 &git_ref_name,
500 RefTarget::normal(CommitId::from_bytes(new_oid.as_bytes())),
501 );
502 } else {
503 failed_branches.push(parsed_ref_name);
504 }
505 }
506 Ok(failed_branches)
507}
508
509pub fn remove_remote(
510 mut_repo: &mut MutableRepo,
511 git_repo: &git2::Repository,
512 remote_name: &str,
513) -> Result<(), git2::Error> {
514 git_repo.remote_delete(remote_name)?;
515 let mut branches_to_delete = vec![];
516 for (branch, target) in mut_repo.view().branches() {
517 if target.remote_targets.contains_key(remote_name) {
518 branches_to_delete.push(branch.clone());
519 }
520 }
521 let prefix = format!("refs/remotes/{remote_name}/");
522 let git_refs_to_delete = mut_repo
523 .view()
524 .git_refs()
525 .keys()
526 .filter_map(|r| r.starts_with(&prefix).then(|| r.clone()))
527 .collect_vec();
528 for branch in branches_to_delete {
529 mut_repo.set_remote_branch_target(&branch, remote_name, RefTarget::absent());
530 }
531 for git_ref in git_refs_to_delete {
532 mut_repo.set_git_ref_target(&git_ref, RefTarget::absent());
533 }
534 Ok(())
535}
536
537pub fn rename_remote(
538 mut_repo: &mut MutableRepo,
539 git_repo: &git2::Repository,
540 old_remote_name: &str,
541 new_remote_name: &str,
542) -> Result<(), git2::Error> {
543 git_repo.remote_rename(old_remote_name, new_remote_name)?;
544 mut_repo.rename_remote(old_remote_name, new_remote_name);
545 let prefix = format!("refs/remotes/{old_remote_name}/");
546 let git_refs = mut_repo
547 .view()
548 .git_refs()
549 .iter()
550 .filter_map(|(r, target)| {
551 r.strip_prefix(&prefix).map(|p| {
552 (
553 r.clone(),
554 format!("refs/remotes/{new_remote_name}/{p}"),
555 target.clone(),
556 )
557 })
558 })
559 .collect_vec();
560 for (old, new, target) in git_refs {
561 mut_repo.set_git_ref_target(&old, RefTarget::absent());
562 mut_repo.set_git_ref_target(&new, target);
563 }
564 Ok(())
565}
566
567#[derive(Error, Debug, PartialEq)]
568pub enum GitFetchError {
569 #[error("No git remote named '{0}'")]
570 NoSuchRemote(String),
571 #[error("Invalid glob provided. Globs may not contain the characters `:` or `^`.")]
572 InvalidGlob,
573 // TODO: I'm sure there are other errors possible, such as transport-level errors.
574 #[error("Unexpected git error when fetching: {0}")]
575 InternalGitError(#[from] git2::Error),
576}
577
578#[tracing::instrument(skip(mut_repo, git_repo, callbacks))]
579pub fn fetch(
580 mut_repo: &mut MutableRepo,
581 git_repo: &git2::Repository,
582 remote_name: &str,
583 branch_name_globs: Option<&[&str]>,
584 callbacks: RemoteCallbacks<'_>,
585 git_settings: &GitSettings,
586) -> Result<Option<String>, GitFetchError> {
587 let branch_name_filter = {
588 let regex = if let Some(globs) = branch_name_globs {
589 let result = regex::RegexSet::new(
590 globs
591 .iter()
592 .map(|glob| format!("^{}$", glob.replace('*', ".*"))),
593 )
594 .map_err(|_| GitFetchError::InvalidGlob)?;
595 tracing::debug!(?globs, ?result, "globs as regex");
596 Some(result)
597 } else {
598 None
599 };
600 move |branch: &str| regex.as_ref().map(|r| r.is_match(branch)).unwrap_or(true)
601 };
602
603 // In non-colocated repositories, it's possible that `jj branch forget` was run
604 // at some point and no `jj git export` happened since.
605 //
606 // This would mean that remote-tracking branches, forgotten in the jj repo,
607 // still exist in the git repo. If the branches didn't move on the remote, and
608 // we fetched them, jj would think that they are unmodified and wouldn't
609 // resurrect them.
610 //
611 // Export will delete the remote-tracking branches in the git repo, so it's
612 // possible to fetch them again.
613 //
614 // For more details, see the `test_branch_forget_fetched_branch` test, and PRs
615 // #1714 and #1771
616 //
617 // Apart from `jj branch forget`, jj doesn't provide commands to manipulate
618 // remote-tracking branches, and local git branches don't affect fetch
619 // behaviors. So, it's unnecessary to export anything else.
620 //
621 // TODO: Create a command the user can use to reset jj's
622 // branch state to the git repo's state. In this case, `jj branch forget`
623 // doesn't work as it tries to delete the latter. One possible name is `jj
624 // git import --reset BRANCH`.
625 // TODO: Once the command described above exists, it should be mentioned in `jj
626 // help branch forget`.
627 let nonempty_branches: HashSet<_> = mut_repo
628 .view()
629 .branches()
630 .iter()
631 .filter_map(|(branch, target)| target.local_target.is_present().then(|| branch.to_owned()))
632 .collect();
633 // TODO: Inform the user if the export failed? In most cases, export is not
634 // essential for fetch to work.
635 let _ = export_some_refs(mut_repo, git_repo, |ref_name| {
636 to_remote_branch(ref_name, remote_name)
637 .map(|branch| branch_name_filter(branch) && !nonempty_branches.contains(branch))
638 .unwrap_or(false)
639 });
640
641 // Perform a `git fetch` on the local git repo, updating the remote-tracking
642 // branches in the git repo.
643 let mut remote =
644 git_repo
645 .find_remote(remote_name)
646 .map_err(|err| match (err.class(), err.code()) {
647 (git2::ErrorClass::Config, git2::ErrorCode::NotFound) => {
648 GitFetchError::NoSuchRemote(remote_name.to_string())
649 }
650 (git2::ErrorClass::Config, git2::ErrorCode::InvalidSpec) => {
651 GitFetchError::NoSuchRemote(remote_name.to_string())
652 }
653 _ => GitFetchError::InternalGitError(err),
654 })?;
655 let mut fetch_options = git2::FetchOptions::new();
656 let mut proxy_options = git2::ProxyOptions::new();
657 proxy_options.auto();
658 fetch_options.proxy_options(proxy_options);
659 let callbacks = callbacks.into_git();
660 fetch_options.remote_callbacks(callbacks);
661 let refspecs = {
662 // If no globs have been given, import all branches
663 let globs = branch_name_globs.unwrap_or(&["*"]);
664 if globs.iter().any(|g| g.contains(|c| ":^".contains(c))) {
665 return Err(GitFetchError::InvalidGlob);
666 }
667 // At this point, we are only updating Git's remote tracking branches, not the
668 // local branches.
669 globs
670 .iter()
671 .map(|glob| format!("+refs/heads/{glob}:refs/remotes/{remote_name}/{glob}"))
672 .collect_vec()
673 };
674 tracing::debug!("remote.download");
675 remote.download(&refspecs, Some(&mut fetch_options))?;
676 tracing::debug!("remote.prune");
677 remote.prune(None)?;
678 tracing::debug!("remote.update_tips");
679 remote.update_tips(None, false, git2::AutotagOption::Unspecified, None)?;
680 // TODO: We could make it optional to get the default branch since we only care
681 // about it on clone.
682 let mut default_branch = None;
683 if let Ok(default_ref_buf) = remote.default_branch() {
684 if let Some(default_ref) = default_ref_buf.as_str() {
685 // LocalBranch here is the local branch on the remote, so it's really the remote
686 // branch
687 if let Some(RefName::LocalBranch(branch_name)) = parse_git_ref(default_ref) {
688 tracing::debug!(default_branch = branch_name);
689 default_branch = Some(branch_name);
690 }
691 }
692 }
693 tracing::debug!("remote.disconnect");
694 remote.disconnect()?;
695
696 // `import_some_refs` will import the remote-tracking branches into the jj repo
697 // and update jj's local branches.
698 tracing::debug!("import_refs");
699 import_some_refs(mut_repo, git_repo, git_settings, |ref_name| {
700 to_remote_branch(ref_name, remote_name)
701 .map(&branch_name_filter)
702 .unwrap_or(false)
703 })
704 .map_err(|err| match err {
705 GitImportError::InternalGitError(source) => GitFetchError::InternalGitError(source),
706 })?;
707 Ok(default_branch)
708}
709
710#[derive(Error, Debug, PartialEq)]
711pub enum GitPushError {
712 #[error("No git remote named '{0}'")]
713 NoSuchRemote(String),
714 #[error("Push is not fast-forwardable")]
715 NotFastForward,
716 #[error("Remote rejected the update of some refs (do you have permission to push to {0:?}?)")]
717 RefUpdateRejected(Vec<String>),
718 // TODO: I'm sure there are other errors possible, such as transport-level errors,
719 // and errors caused by the remote rejecting the push.
720 #[error("Unexpected git error when pushing: {0}")]
721 InternalGitError(#[from] git2::Error),
722}
723
724pub struct GitRefUpdate {
725 pub qualified_name: String,
726 // TODO: We want this to be a `current_target: Option<CommitId>` for the expected current
727 // commit on the remote. It's a blunt "force" option instead until git2-rs supports the
728 // "push negotiation" callback (https://github.com/rust-lang/git2-rs/issues/733).
729 pub force: bool,
730 pub new_target: Option<CommitId>,
731}
732
733pub fn push_updates(
734 git_repo: &git2::Repository,
735 remote_name: &str,
736 updates: &[GitRefUpdate],
737 callbacks: RemoteCallbacks<'_>,
738) -> Result<(), GitPushError> {
739 let mut temp_refs = vec![];
740 let mut qualified_remote_refs = vec![];
741 let mut refspecs = vec![];
742 for update in updates {
743 qualified_remote_refs.push(update.qualified_name.as_str());
744 if let Some(new_target) = &update.new_target {
745 // Create a temporary ref to work around https://github.com/libgit2/libgit2/issues/3178
746 let temp_ref_name = format!("refs/jj/git-push/{}", new_target.hex());
747 temp_refs.push(git_repo.reference(
748 &temp_ref_name,
749 git2::Oid::from_bytes(new_target.as_bytes()).unwrap(),
750 true,
751 "temporary reference for git push",
752 )?);
753 refspecs.push(format!(
754 "{}{}:{}",
755 (if update.force { "+" } else { "" }),
756 temp_ref_name,
757 update.qualified_name
758 ));
759 } else {
760 refspecs.push(format!(":{}", update.qualified_name));
761 }
762 }
763 let result = push_refs(
764 git_repo,
765 remote_name,
766 &qualified_remote_refs,
767 &refspecs,
768 callbacks,
769 );
770 for mut temp_ref in temp_refs {
771 // TODO: Figure out how to do the equivalent of absl::Cleanup for
772 // temp_ref.delete().
773 if let Err(err) = temp_ref.delete() {
774 // Propagate error only if we don't already have an error to return and it's not
775 // NotFound (there may be duplicates if the list if multiple branches moved to
776 // the same commit).
777 if result.is_ok() && err.code() != git2::ErrorCode::NotFound {
778 return Err(GitPushError::InternalGitError(err));
779 }
780 }
781 }
782 result
783}
784
785fn push_refs(
786 git_repo: &git2::Repository,
787 remote_name: &str,
788 qualified_remote_refs: &[&str],
789 refspecs: &[String],
790 callbacks: RemoteCallbacks<'_>,
791) -> Result<(), GitPushError> {
792 let mut remote =
793 git_repo
794 .find_remote(remote_name)
795 .map_err(|err| match (err.class(), err.code()) {
796 (git2::ErrorClass::Config, git2::ErrorCode::NotFound) => {
797 GitPushError::NoSuchRemote(remote_name.to_string())
798 }
799 (git2::ErrorClass::Config, git2::ErrorCode::InvalidSpec) => {
800 GitPushError::NoSuchRemote(remote_name.to_string())
801 }
802 _ => GitPushError::InternalGitError(err),
803 })?;
804 let mut remaining_remote_refs: HashSet<_> = qualified_remote_refs.iter().copied().collect();
805 let mut push_options = git2::PushOptions::new();
806 let mut proxy_options = git2::ProxyOptions::new();
807 proxy_options.auto();
808 push_options.proxy_options(proxy_options);
809 let mut callbacks = callbacks.into_git();
810 callbacks.push_update_reference(|refname, status| {
811 // The status is Some if the ref update was rejected
812 if status.is_none() {
813 remaining_remote_refs.remove(refname);
814 }
815 Ok(())
816 });
817 push_options.remote_callbacks(callbacks);
818 remote
819 .push(refspecs, Some(&mut push_options))
820 .map_err(|err| match (err.class(), err.code()) {
821 (git2::ErrorClass::Reference, git2::ErrorCode::NotFastForward) => {
822 GitPushError::NotFastForward
823 }
824 _ => GitPushError::InternalGitError(err),
825 })?;
826 drop(push_options);
827 if remaining_remote_refs.is_empty() {
828 Ok(())
829 } else {
830 Err(GitPushError::RefUpdateRejected(
831 remaining_remote_refs
832 .iter()
833 .sorted()
834 .map(|name| name.to_string())
835 .collect(),
836 ))
837 }
838}
839
840#[non_exhaustive]
841#[derive(Default)]
842#[allow(clippy::type_complexity)]
843pub struct RemoteCallbacks<'a> {
844 pub progress: Option<&'a mut dyn FnMut(&Progress)>,
845 pub get_ssh_key: Option<&'a mut dyn FnMut(&str) -> Option<PathBuf>>,
846 pub get_password: Option<&'a mut dyn FnMut(&str, &str) -> Option<String>>,
847 pub get_username_password: Option<&'a mut dyn FnMut(&str) -> Option<(String, String)>>,
848}
849
850impl<'a> RemoteCallbacks<'a> {
851 fn into_git(mut self) -> git2::RemoteCallbacks<'a> {
852 let mut callbacks = git2::RemoteCallbacks::new();
853 if let Some(progress_cb) = self.progress {
854 callbacks.transfer_progress(move |progress| {
855 progress_cb(&Progress {
856 bytes_downloaded: (progress.received_objects() < progress.total_objects())
857 .then(|| progress.received_bytes() as u64),
858 overall: (progress.indexed_objects() + progress.indexed_deltas()) as f32
859 / (progress.total_objects() + progress.total_deltas()) as f32,
860 });
861 true
862 });
863 }
864 // TODO: We should expose the callbacks to the caller instead -- the library
865 // crate shouldn't read environment variables.
866 callbacks.credentials(move |url, username_from_url, allowed_types| {
867 let span = tracing::debug_span!("RemoteCallbacks.credentials");
868 let _ = span.enter();
869
870 let git_config = git2::Config::open_default();
871 let credential_helper = git_config
872 .and_then(|conf| git2::Cred::credential_helper(&conf, url, username_from_url));
873 if let Ok(creds) = credential_helper {
874 tracing::info!("using credential_helper");
875 return Ok(creds);
876 } else if let Some(username) = username_from_url {
877 if allowed_types.contains(git2::CredentialType::SSH_KEY) {
878 // Try to get the SSH key from the agent by default, and report an error
879 // only if it _seems_ like that's what the user wanted.
880 //
881 // Note that the env variables read below are **not** the only way to
882 // communicate with the agent, which is why we request a key from it no
883 // matter what.
884 match git2::Cred::ssh_key_from_agent(username) {
885 Ok(key) => {
886 tracing::info!(username, "using ssh_key_from_agent");
887 return Ok(key);
888 }
889 Err(err) => {
890 if std::env::var("SSH_AUTH_SOCK").is_ok()
891 || std::env::var("SSH_AGENT_PID").is_ok()
892 {
893 tracing::error!(err = %err);
894 return Err(err);
895 }
896 // There is no agent-related env variable so we
897 // consider that the user doesn't care about using
898 // the agent and proceed.
899 }
900 }
901
902 if let Some(ref mut cb) = self.get_ssh_key {
903 if let Some(path) = cb(username) {
904 tracing::info!(username, path = ?path, "using ssh_key");
905 return git2::Cred::ssh_key(username, None, &path, None).map_err(
906 |err| {
907 tracing::error!(err = %err);
908 err
909 },
910 );
911 }
912 }
913 }
914 if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
915 if let Some(ref mut cb) = self.get_password {
916 if let Some(pw) = cb(url, username) {
917 tracing::info!(
918 username,
919 "using userpass_plaintext with username from url"
920 );
921 return git2::Cred::userpass_plaintext(username, &pw).map_err(|err| {
922 tracing::error!(err = %err);
923 err
924 });
925 }
926 }
927 }
928 } else if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
929 if let Some(ref mut cb) = self.get_username_password {
930 if let Some((username, pw)) = cb(url) {
931 tracing::info!(username, "using userpass_plaintext");
932 return git2::Cred::userpass_plaintext(&username, &pw).map_err(|err| {
933 tracing::error!(err = %err);
934 err
935 });
936 }
937 }
938 }
939 tracing::info!("using default");
940 git2::Cred::default()
941 });
942 callbacks
943 }
944}
945
946pub struct Progress {
947 /// `Some` iff data transfer is currently in progress
948 pub bytes_downloaded: Option<u64>,
949 pub overall: f32,
950}
951
952#[derive(Default)]
953struct PartialSubmoduleConfig {
954 path: Option<String>,
955 url: Option<String>,
956}
957
958/// Represents configuration from a submodule, e.g. in .gitmodules
959/// This doesn't include all possible fields, only the ones we care about
960#[derive(Debug, PartialEq, Eq)]
961pub struct SubmoduleConfig {
962 pub name: String,
963 pub path: String,
964 pub url: String,
965}
966
967#[derive(Error, Debug)]
968pub enum GitConfigParseError {
969 #[error("Unexpected io error when parsing config: {0}")]
970 IoError(#[from] std::io::Error),
971 #[error("Unexpected git error when parsing config: {0}")]
972 InternalGitError(#[from] git2::Error),
973}
974
975pub fn parse_gitmodules(
976 config: &mut dyn Read,
977) -> Result<BTreeMap<String, SubmoduleConfig>, GitConfigParseError> {
978 // git2 can only read from a path, so set one up
979 let mut temp_file = NamedTempFile::new()?;
980 std::io::copy(config, &mut temp_file)?;
981 let path = temp_file.into_temp_path();
982 let git_config = git2::Config::open(&path)?;
983 // Partial config value for each submodule name
984 let mut partial_configs: BTreeMap<String, PartialSubmoduleConfig> = BTreeMap::new();
985
986 let entries = git_config.entries(Some(r"submodule\..+\."))?;
987 entries.for_each(|entry| {
988 let (config_name, config_value) = match (entry.name(), entry.value()) {
989 // Reject non-utf8 entries
990 (Some(name), Some(value)) => (name, value),
991 _ => return,
992 };
993
994 // config_name is of the form submodule.<name>.<variable>
995 let (submod_name, submod_var) = config_name
996 .strip_prefix("submodule.")
997 .unwrap()
998 .split_once('.')
999 .unwrap();
1000
1001 let map_entry = partial_configs.entry(submod_name.to_string()).or_default();
1002
1003 match (submod_var.to_ascii_lowercase().as_str(), &map_entry) {
1004 // TODO Git warns when a duplicate config entry is found, we should
1005 // consider doing the same.
1006 ("path", PartialSubmoduleConfig { path: None, .. }) => {
1007 map_entry.path = Some(config_value.to_string())
1008 }
1009 ("url", PartialSubmoduleConfig { url: None, .. }) => {
1010 map_entry.url = Some(config_value.to_string())
1011 }
1012 _ => (),
1013 };
1014 })?;
1015
1016 let ret = partial_configs
1017 .into_iter()
1018 .filter_map(|(name, val)| {
1019 Some((
1020 name.clone(),
1021 SubmoduleConfig {
1022 name,
1023 path: val.path?,
1024 url: val.url?,
1025 },
1026 ))
1027 })
1028 .collect();
1029 Ok(ret)
1030}