use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use gix::bstr::{BStr, ByteSlice};
use gix::object::tree::diff::{Action, Change};
use gix::objs::tree::EntryKind;
use crate::entity::Head;
use crate::git;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Ineligible {
NotClean,
NoUpstream,
NotBehind,
NotFastForward,
}
#[derive(Debug)]
#[allow(dead_code)]
pub(crate) enum Outcome {
Ineligible(Ineligible),
Updated {
from: gix::ObjectId,
to: gix::ObjectId,
},
Failed(String),
}
pub(crate) fn attempt(path: &Path) -> Outcome {
let repo = match gix::open(path) {
Ok(repo) => repo,
Err(error) => return Outcome::Failed(describe_error_chain(error)),
};
let head = match git::head_shape(&repo) {
Ok(head) => head,
Err(error) => return Outcome::Failed(describe_error_chain(error)),
};
let Head::Branch {
name,
commit: local_commit,
} = head
else {
return Outcome::Ineligible(Ineligible::NoUpstream);
};
if !git::has_any_remote(&repo) {
return Outcome::Ineligible(Ineligible::NoUpstream);
}
let Some(upstream_commit) = git::upstream_commit(&repo, &name) else {
return Outcome::Ineligible(Ineligible::NoUpstream);
};
let ahead_behind = match git::ahead_behind(&repo, local_commit, upstream_commit) {
Ok(counts) => counts,
Err(error) => return Outcome::Failed(error),
};
if ahead_behind.ahead > 0 {
return Outcome::Ineligible(Ineligible::NotFastForward);
}
if ahead_behind.behind == 0 {
return Outcome::Ineligible(Ineligible::NotBehind);
}
match is_repo_clean_for_auto_update(&repo, local_commit) {
Ok(true) => {}
Ok(false) => return Outcome::Ineligible(Ineligible::NotClean),
Err(error) => return Outcome::Failed(error),
}
match is_ancestor(&repo, local_commit, upstream_commit) {
Ok(true) => {}
Ok(false) => return Outcome::Ineligible(Ineligible::NotFastForward),
Err(error) => return Outcome::Failed(error),
}
match fast_forward(&repo, path, &name, local_commit, upstream_commit) {
Ok(()) => Outcome::Updated {
from: local_commit,
to: upstream_commit,
},
Err(error) => Outcome::Failed(error),
}
}
fn is_ancestor(
repo: &gix::Repository,
from: gix::ObjectId,
to: gix::ObjectId,
) -> Result<bool, String> {
Ok(git::checked_merge_base(repo, from, to)? == Some(from))
}
fn is_repo_clean_for_auto_update(
repo: &gix::Repository,
head_commit: gix::ObjectId,
) -> Result<bool, String> {
let cancel = Arc::new(AtomicBool::new(false));
let counts = git::dirty_counts(repo, cancel).map_err(describe_error_chain)?;
if counts.total() > 0 {
return Ok(false);
}
let head_tree = repo
.find_commit(head_commit)
.map_err(describe_error_chain)?
.tree_id()
.map_err(describe_error_chain)?
.detach();
let expected_index = repo
.index_from_tree(&head_tree)
.map_err(describe_error_chain)?;
let actual_index = repo.open_index().map_err(describe_error_chain)?;
Ok(index_snapshot(&expected_index) == index_snapshot(&actual_index))
}
fn index_snapshot(
index: &gix::index::File,
) -> BTreeMap<Vec<u8>, (gix::index::entry::Mode, gix::ObjectId)> {
let backing = index.path_backing();
index
.entries()
.iter()
.map(|entry| (entry.path_in(backing).to_vec(), (entry.mode, entry.id)))
.collect()
}
fn fast_forward(
repo: &gix::Repository,
work_dir: &Path,
branch_name: &str,
from: gix::ObjectId,
to: gix::ObjectId,
) -> Result<(), String> {
let from_tree = repo
.find_commit(from)
.map_err(describe_error_chain)?
.tree()
.map_err(describe_error_chain)?;
let to_commit = repo.find_commit(to).map_err(describe_error_chain)?;
let to_tree = to_commit.tree().map_err(describe_error_chain)?;
let to_tree_id = to_commit.tree_id().map_err(describe_error_chain)?.detach();
let mut changes = from_tree.changes().map_err(describe_error_chain)?;
changes.options(|options| {
options.track_path();
options.track_rewrites(None);
});
changes
.for_each_to_obtain_tree(&to_tree, |change| -> Result<Action, String> {
apply_change(change, work_dir)?;
Ok(Action::Continue(()))
})
.map_err(describe_error_chain)?;
let mut new_index = repo
.index_from_tree(&to_tree_id)
.map_err(describe_error_chain)?;
new_index
.write(Default::default())
.map_err(describe_error_chain)?;
let full_ref = format!("refs/heads/{branch_name}");
let mut reference = repo
.find_reference(full_ref.as_str())
.map_err(describe_error_chain)?;
reference
.set_target_id(to, "repon: fast-forward auto-update")
.map_err(describe_error_chain)?;
Ok(())
}
fn apply_change(change: Change<'_, '_, '_>, work_dir: &Path) -> Result<(), String> {
match change {
Change::Addition {
location,
entry_mode,
id,
..
}
| Change::Modification {
location,
entry_mode,
id,
..
} => write_entry(work_dir, location, entry_mode, id),
Change::Deletion { location, .. } => remove_entry(work_dir, location),
Change::Rewrite {
source_location,
location,
entry_mode,
id,
..
} => {
if source_location != location {
remove_entry(work_dir, source_location)?;
}
write_entry(work_dir, location, entry_mode, id)
}
}
}
fn describe_error_chain<E: std::error::Error>(error: E) -> String {
let mut message = format!("{error}").replace('\n', " ");
let mut source = error.source();
while let Some(current) = source {
message.push_str(": ");
message.push_str(&format!("{current}").replace('\n', " "));
source = current.source();
}
message
}
fn relative_path(location: &BStr) -> Result<&Path, String> {
location.to_str().map(Path::new).map_err(|error| {
format!(
"non-UTF-8 path in a tree diff: {}",
describe_error_chain(error)
)
})
}
fn write_entry(
work_dir: &Path,
location: &BStr,
entry_mode: gix::objs::tree::EntryMode,
id: gix::Id<'_>,
) -> Result<(), String> {
let full_path = work_dir.join(relative_path(location)?);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).map_err(describe_error_chain)?;
}
let object = id.object().map_err(describe_error_chain)?;
match entry_mode.kind() {
EntryKind::Blob => {
fs::write(&full_path, &object.data).map_err(describe_error_chain)?;
set_permissions(&full_path, 0o644)?;
}
EntryKind::BlobExecutable => {
fs::write(&full_path, &object.data).map_err(describe_error_chain)?;
set_permissions(&full_path, 0o755)?;
}
EntryKind::Link => {
let target = object
.data
.to_path()
.map_err(describe_error_chain)?
.to_path_buf();
match fs::remove_file(&full_path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(describe_error_chain(error)),
}
std::os::unix::fs::symlink(target, &full_path).map_err(describe_error_chain)?;
}
EntryKind::Tree => {
return Err(format!(
"unexpected tree-shaped leaf change at {}",
full_path.display()
));
}
EntryKind::Commit => {
return Err(format!(
"a submodule pointer changed at {}; the auto-update does not update submodules",
full_path.display()
));
}
}
Ok(())
}
fn set_permissions(path: &Path, mode: u32) -> Result<(), String> {
fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(describe_error_chain)
}
fn remove_entry(work_dir: &Path, location: &BStr) -> Result<(), String> {
let full_path = work_dir.join(relative_path(location)?);
match fs::remove_file(&full_path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(describe_error_chain(error)),
}
let mut dir = full_path.parent();
while let Some(candidate) = dir {
if candidate == work_dir || fs::remove_dir(candidate).is_err() {
break;
}
dir = candidate.parent();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{
commit_file, current_branch, git, head_sha, push_new_commit, remote_and_clone,
};
fn balanced_parens(source: &str, open: usize) -> &str {
let mut depth = 1usize;
for (offset, character) in source[open..].char_indices() {
match character {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return &source[open..open + offset];
}
}
_ => {}
}
}
panic!("unbalanced parentheses in module source starting at index {open}");
}
#[test]
fn every_map_err_closure_in_this_module_renders_through_describe_error_chain() {
let source = include_str!("auto_update.rs");
let production = source
.split("#[cfg(test)]")
.next()
.expect("splitting on a literal always yields at least the part before it");
let mut checked = 0;
let mut search_from = 0;
while let Some(relative) = production[search_from..].find(".map_err(") {
let open = search_from + relative + ".map_err(".len();
let argument = balanced_parens(production, open);
assert!(
argument.contains("describe_error_chain"),
"a .map_err( call does not render through describe_error_chain: {argument:?}"
);
checked += 1;
search_from = open;
}
assert!(
checked >= 20,
"expected at least 20 .map_err( sites in this module, found {checked}; \
the scan's own marker may no longer match this file's shape"
);
}
#[test]
fn two_bare_err_arms_in_write_entry_and_remove_entry_also_render_through_describe_error_chain()
{
let source = include_str!("auto_update.rs");
let production = source
.split("#[cfg(test)]")
.next()
.expect("splitting on a literal always yields at least the part before it");
let occurrences = production
.matches("Err(error) => return Err(describe_error_chain(error)),")
.count();
assert_eq!(
occurrences, 2,
"expected exactly 2 bare `Err(error) => return Err(describe_error_chain(error)),` \
arms (write_entry's symlink overwrite and remove_entry), found {occurrences}"
);
}
fn read_file(path: &Path) -> String {
std::fs::read_to_string(path).unwrap_or_else(|error| panic!("read {path:?}: {error}"))
}
#[derive(Debug)]
struct LeafError(&'static str);
impl std::fmt::Display for LeafError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for LeafError {}
#[derive(Debug)]
struct WrapperError {
message: &'static str,
source: Box<dyn std::error::Error + 'static>,
}
impl std::fmt::Display for WrapperError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for WrapperError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.source.as_ref())
}
}
#[test]
fn describe_error_chain_of_a_sourceless_error_is_exactly_its_own_message() {
let error = LeafError("disk is full");
let rendered = describe_error_chain(error);
assert_eq!(rendered, "disk is full");
}
#[test]
fn describe_error_chain_of_a_two_deep_source_includes_every_level_outermost_first() {
let root_cause = LeafError("permission denied");
let middle = WrapperError {
message: "the user-provided callback failed",
source: Box::new(root_cause),
};
let outer = WrapperError {
message: "fast-forward failed",
source: Box::new(middle),
};
let rendered = describe_error_chain(&outer);
assert_eq!(
rendered,
"fast-forward failed: the user-provided callback failed: permission denied"
);
assert!(
!rendered.contains('\n'),
"the message must stay on one line"
);
}
#[test]
fn describe_error_chain_flattens_a_multi_line_source_onto_one_line() {
let error = WrapperError {
message: "fast-forward failed",
source: Box::new(LeafError("disk is full\nretry later")),
};
let rendered = describe_error_chain(error);
assert_eq!(rendered, "fast-forward failed: disk is full retry later");
assert!(
!rendered.contains('\n'),
"the message must stay on one line"
);
}
#[test]
fn an_eligible_repo_fast_forwards_to_its_upstream_and_updates_the_working_tree() {
let (remote, clone) = remote_and_clone();
push_new_commit(remote.path(), "second.txt", "second\n");
git(clone.path(), &["fetch", "origin"]);
let upstream_sha = git_rev_parse(clone.path(), "refs/remotes/origin/main");
let outcome = attempt(clone.path());
match outcome {
Outcome::Updated { to, .. } => assert_eq!(to.to_string(), upstream_sha),
other => panic!("expected an eligible repo to update, got {other:?}"),
}
assert_eq!(
git_rev_parse(clone.path(), "refs/heads/main"),
upstream_sha,
"the local branch ref must now equal the upstream it fast-forwarded to"
);
assert_eq!(
read_file(&clone.path().join("second.txt")),
"second\n",
"the new commit's file must be checked out into the working tree"
);
}
#[test]
fn a_fast_forward_removes_a_file_the_new_commit_deleted() {
let (remote, clone) = remote_and_clone();
push_new_commit(remote.path(), "doomed.txt", "will be removed\n");
git(clone.path(), &["fetch", "origin"]);
git(clone.path(), &["merge", "--ff-only", "origin/main"]);
assert!(clone.path().join("doomed.txt").exists());
push_removed_file(remote.path(), "doomed.txt");
git(clone.path(), &["fetch", "origin"]);
let outcome = attempt(clone.path());
assert!(
matches!(outcome, Outcome::Updated { .. }),
"expected the deletion to still be a clean fast-forward, got {outcome:?}"
);
assert!(
!clone.path().join("doomed.txt").exists(),
"the fast-forward must remove a file the new commit no longer has"
);
}
#[test]
fn a_fast_forward_failure_from_a_callback_error_reports_the_callback_message() {
let (remote, clone) = remote_and_clone();
let from_sha = git_rev_parse(clone.path(), "refs/heads/main");
push_new_commit(remote.path(), "blocked", "new content\n");
git(clone.path(), &["fetch", "origin"]);
let to_sha = git_rev_parse(clone.path(), "refs/remotes/origin/main");
std::fs::create_dir(clone.path().join("blocked")).expect("create a blocking directory");
let repo = gix::open(clone.path()).expect("open the clone");
let from = gix::ObjectId::from_hex(from_sha.as_bytes()).expect("parse from sha");
let to = gix::ObjectId::from_hex(to_sha.as_bytes()).expect("parse to sha");
let error = fast_forward(&repo, clone.path(), "main", from, to)
.expect_err("a directory blocking the new file must fail the fast-forward");
assert!(
error.starts_with("The user-provided callback failed: "),
"expected the wrapper's own message followed by its cause, got {error:?}"
);
assert_ne!(
error, "The user-provided callback failed",
"the callback's own message must not be dropped"
);
}
fn push_removed_file(remote: &Path, name: &str) {
let contributor = tempfile::tempdir().expect("temp dir");
let status = std::process::Command::new("git")
.arg("clone")
.arg(remote)
.arg(contributor.path())
.status()
.expect("run git clone");
assert!(status.success());
git(contributor.path(), &["rm", name]);
git(contributor.path(), &["commit", "-m", "remove a file"]);
git(contributor.path(), &["push", "origin", "main"]);
}
fn git_rev_parse(path: &Path, rev: &str) -> String {
let output = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", rev])
.output()
.expect("run git rev-parse");
assert!(output.status.success(), "git rev-parse {rev} failed");
String::from_utf8(output.stdout)
.expect("utf8 output")
.trim()
.to_string()
}
#[test]
fn a_dirty_repo_is_ineligible_and_left_untouched() {
let (remote, clone) = remote_and_clone();
push_new_commit(remote.path(), "second.txt", "second\n");
git(clone.path(), &["fetch", "origin"]);
let before = git_rev_parse(clone.path(), "refs/heads/main");
std::fs::write(clone.path().join("untracked.txt"), "uncommitted\n")
.expect("write an untracked file");
let outcome = attempt(clone.path());
assert!(
matches!(outcome, Outcome::Ineligible(Ineligible::NotClean)),
"expected NotClean, got {outcome:?}"
);
assert_eq!(
git_rev_parse(clone.path(), "refs/heads/main"),
before,
"a dirty repo's branch must not move"
);
let repo = gix::open(clone.path()).expect("open the clone");
let counts =
git::dirty_counts(&repo, Arc::new(AtomicBool::new(false))).expect("dirty counts");
assert_eq!(
counts.untracked, 1,
"the dirty count must still truthfully report the untracked file, not hide it"
);
}
#[test]
fn an_up_to_date_repo_is_ineligible_and_left_untouched() {
let (_remote, clone) = remote_and_clone();
git(clone.path(), &["fetch", "origin"]);
let before = git_rev_parse(clone.path(), "refs/heads/main");
let outcome = attempt(clone.path());
assert!(
matches!(outcome, Outcome::Ineligible(Ineligible::NotBehind)),
"expected NotBehind, got {outcome:?}"
);
assert_eq!(git_rev_parse(clone.path(), "refs/heads/main"), before);
let repo = gix::open(clone.path()).expect("open the clone");
let head = git::head_shape(&repo).expect("head shape");
let Head::Branch { commit, .. } = head else {
panic!("expected a branch head")
};
let sync = git::resolve_sync(&repo, Some(&head)).expect("resolve sync");
assert!(
matches!(
sync,
crate::entity::SyncState::Tracking(crate::entity::AheadBehind {
ahead: 0,
behind: 0
})
),
"the true, level sync state must still be what a fresh read reports, got \
{sync:?} for commit {commit}"
);
}
#[test]
fn a_repo_with_an_unpublished_local_commit_is_ineligible_and_left_untouched() {
let (remote, clone) = remote_and_clone();
push_new_commit(remote.path(), "second.txt", "second\n");
git(clone.path(), &["fetch", "origin"]);
commit_file(clone.path(), "local-only.txt", "never pushed\n");
let before = git_rev_parse(clone.path(), "refs/heads/main");
let outcome = attempt(clone.path());
assert!(
matches!(outcome, Outcome::Ineligible(Ineligible::NotFastForward)),
"expected NotFastForward, got {outcome:?}"
);
assert_eq!(
git_rev_parse(clone.path(), "refs/heads/main"),
before,
"a repo with an unpublished commit must not move"
);
let repo = gix::open(clone.path()).expect("open the clone");
let head = git::head_shape(&repo).expect("head shape");
let sync = git::resolve_sync(&repo, Some(&head)).expect("resolve sync");
assert!(
matches!(
sync,
crate::entity::SyncState::Tracking(crate::entity::AheadBehind { ahead: 1, .. })
),
"the true ahead count must still be reported, got {sync:?}"
);
}
#[test]
fn a_branch_with_no_upstream_is_ineligible_and_left_untouched() {
let (_remote, clone) = remote_and_clone();
git(clone.path(), &["checkout", "-b", "untracked-branch"]);
let before = git_rev_parse(clone.path(), "refs/heads/untracked-branch");
let outcome = attempt(clone.path());
assert!(
matches!(outcome, Outcome::Ineligible(Ineligible::NoUpstream)),
"expected NoUpstream, got {outcome:?}"
);
assert_eq!(
git_rev_parse(clone.path(), "refs/heads/untracked-branch"),
before
);
let repo = gix::open(clone.path()).expect("open the clone");
let head = git::head_shape(&repo).expect("head shape");
let sync = git::resolve_sync(&repo, Some(&head)).expect("resolve sync");
assert_eq!(
sync,
crate::entity::SyncState::NoUpstream,
"the true absence of an upstream must still be what a fresh read reports"
);
assert_eq!(current_branch(clone.path()), "untracked-branch");
}
#[test]
fn is_ancestor_refuses_two_commits_with_unrelated_histories() {
let dir = tempfile::tempdir().expect("temp dir");
git(dir.path(), &["init", "-q", "--initial-branch=main"]);
commit_file(dir.path(), "a.txt", "a\n");
let first = head_sha(dir.path());
git(dir.path(), &["checkout", "--orphan", "unrelated"]);
git(dir.path(), &["rm", "-rf", "."]);
commit_file(dir.path(), "b.txt", "b\n");
let second = head_sha(dir.path());
let repo = gix::open(dir.path()).expect("open");
let from = gix::ObjectId::from_hex(first.as_bytes()).expect("parse sha");
let to = gix::ObjectId::from_hex(second.as_bytes()).expect("parse sha");
assert_eq!(is_ancestor(&repo, from, to), Ok(false));
}
}