use std::collections::HashMap;
use time::OffsetDateTime;
use crate::change_id::{change_id, resolved_change_id, set_change_id, ChangeId, ChangeIdPolicy};
use crate::ident_resolve::resolve_loose_committer_identity_with;
use crate::merge_base::{walk_commits_reachable_excluding_ancestors_of, ReachableWalkLimit};
use crate::objects::{serialize_commit, ObjectId, ObjectKind};
use crate::repo::Repository;
use crate::state::{detect_in_progress, resolve_head_for_repository, HeadState};
use super::graft::{parents_first, without_signatures};
use super::refs::{log_head_move_as, swap_ref_as, Swap};
use super::tree::{diff, lookup, Change};
use super::{branches_with_work, in_view, CheckoutMarker, Result};
const REFLOG_MESSAGE: &str = "change ids: give commits made with git a change id";
pub fn change_keys(
repo: &Repository,
include: &[ObjectId],
exclude: &[ObjectId],
) -> Result<Vec<(ObjectId, ChangeId)>> {
let (_, newest_first) = walk_commits_reachable_excluding_ancestors_of(
repo,
include,
exclude,
ReachableWalkLimit::Unlimited,
)?;
let odb = repo.odb();
parents_first(repo, &newest_first)?
.into_iter()
.map(|oid| {
let data = odb.read_commit(&oid)?;
Ok((oid, resolved_change_id(&oid, &data).id))
})
.collect()
}
pub fn keeps_every_commit(
repo: &Repository,
previous: ObjectId,
grafted: ObjectId,
targets: &[ObjectId],
paths: &[String],
) -> Result<bool> {
let mut not_grafted = vec![grafted];
not_grafted.extend_from_slice(targets);
let dropped = change_keys(repo, &[previous], ¬_grafted)?;
if dropped.is_empty() {
return Ok(true);
}
let mut not_previous = vec![previous];
not_previous.extend_from_slice(targets);
let mut copies: HashMap<ChangeId, Vec<ObjectId>> = HashMap::new();
for (oid, key) in change_keys(repo, &[grafted], ¬_previous)? {
copies.entry(key).or_default().push(oid);
}
for (original, key) in &dropped {
let Some(copies) = copies.get(key) else {
return Ok(false);
};
if !one_holds(repo, copies, &changes_outside(repo, *original, paths)?)? {
return Ok(false);
}
}
Ok(true)
}
fn changes_outside(repo: &Repository, commit: ObjectId, paths: &[String]) -> Result<Vec<Change>> {
if paths.is_empty() {
return Ok(Vec::new());
}
let odb = repo.odb();
let data = odb.read_commit(&commit)?;
let parent_tree = match data.parents.first() {
Some(parent) => Some(odb.commit_tree(parent)?),
None => None,
};
Ok(diff(odb, parent_tree, Some(data.tree))?
.into_iter()
.filter(|change| !in_view(&change.path, paths))
.collect())
}
fn one_holds(repo: &Repository, commits: &[ObjectId], changes: &[Change]) -> Result<bool> {
if changes.is_empty() {
return Ok(true);
}
let odb = repo.odb();
'commits: for commit in commits {
let tree = odb.commit_tree(commit)?;
for change in changes {
if lookup(odb, tree, &change.path)? != change.new {
continue 'commits;
}
}
return Ok(true);
}
Ok(false)
}
pub fn give_change_ids(
repo: &Repository,
marker: &CheckoutMarker,
only: Option<&[String]>,
now: OffsetDateTime,
) -> Result<usize> {
if operation_in_progress(repo)? {
return Ok(0);
}
let config = repo.config()?;
if !ChangeIdPolicy::from_config(config.as_ref()).enabled() {
return Ok(0);
}
let branches: Vec<(String, ObjectId)> = branches_with_work(repo, marker)?
.into_iter()
.filter(|(name, _)| only.is_none_or(|only| only.iter().any(|o| o == name)))
.collect();
if branches.is_empty() {
return Ok(0);
}
let odb = repo.odb();
let mut hide: Vec<ObjectId> = marker
.roots
.iter()
.map(|root| root.commit.as_str())
.chain(marker.landed.iter().map(String::as_str))
.filter_map(|hex| ObjectId::from_hex(hex).ok())
.collect();
hide.extend(
crate::refs::list_refs(repo.git_dir(), "refs/remotes/")?
.into_iter()
.map(|(_, oid)| oid)
.filter(|oid| odb.read(oid).is_ok_and(|o| o.kind == ObjectKind::Commit)),
);
let tips: Vec<ObjectId> = branches.iter().map(|(_, tip)| *tip).collect();
let (_, newest_first) = walk_commits_reachable_excluding_ancestors_of(
repo,
&tips,
&hide,
ReachableWalkLimit::Unlimited,
)?;
let mut rewritten: HashMap<ObjectId, ObjectId> = HashMap::new();
let mut given = 0;
for commit in parents_first(repo, &newest_first)? {
let mut data = odb.read_commit(&commit)?;
let mut moved = false;
for parent in &mut data.parents {
if let Some(new) = rewritten.get(parent) {
*parent = *new;
moved = true;
}
}
let has_id = change_id(&data).is_some();
if has_id && !moved {
continue;
}
data.extra_headers = without_signatures(&data.extra_headers);
if !has_id {
set_change_id(&mut data, &ChangeId::derived_from_commit_id(&commit));
given += 1;
}
rewritten.insert(
commit,
odb.write(ObjectKind::Commit, &serialize_commit(&data))?,
);
}
if rewritten.is_empty() {
return Ok(0);
}
let identity = resolve_loose_committer_identity_with(repo.environment(), &config, now);
move_rewritten(repo, &branches, &rewritten, &identity)?;
Ok(given)
}
fn operation_in_progress(repo: &Repository) -> Result<bool> {
let mut git_dirs = vec![repo.git_dir().to_path_buf()];
git_dirs.extend(
crate::worktree::list_worktrees(repo)?
.into_iter()
.map(|worktree| worktree.admin_dir),
);
Ok(git_dirs.iter().any(|git_dir| {
!detect_in_progress(git_dir).is_empty() || git_dir.join("sequencer/todo").is_file()
}))
}
fn move_rewritten(
repo: &Repository,
branches: &[(String, ObjectId)],
rewritten: &HashMap<ObjectId, ObjectId>,
identity: &str,
) -> Result<()> {
let head = resolve_head_for_repository(repo)?;
for (name, tip) in branches {
let Some(new) = rewritten.get(tip) else {
continue;
};
let refname = format!("refs/heads/{name}");
let swapped = swap_ref_as(repo, &refname, Some(*tip), *new, identity, REFLOG_MESSAGE)?;
if swapped == Swap::Done
&& matches!(&head, HeadState::Branch { refname: current, .. } if *current == refname)
{
log_head_move_as(repo, *tip, *new, identity, REFLOG_MESSAGE)?;
}
}
if let HeadState::Detached { oid } = head {
if let Some(new) = rewritten.get(&oid) {
swap_ref_as(repo, "HEAD", Some(oid), *new, identity, REFLOG_MESSAGE)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repo::InitOptions;
use crate::snapshot_checkout::tree::edit;
use crate::snapshot_checkout::{graft, make_snapshot, SnapshotRoot};
const X: &str = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
const Y: &str = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy";
const Z: &str = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
fn now() -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap_or(OffsetDateTime::UNIX_EPOCH)
}
struct Checkout {
_dir: tempfile::TempDir,
repo: Repository,
snapshot: ObjectId,
marker: CheckoutMarker,
}
fn checkout() -> Checkout {
let dir = tempfile::tempdir().expect("tempdir");
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
let odb = repo.odb();
let blob = |text: &str| {
(
0o100644,
odb.write(ObjectKind::Blob, text.as_bytes()).expect("blob"),
)
};
let full = edit(
odb,
None,
&[
(b"src/a".to_vec(), Some(blob("a"))),
(b"docs/d".to_vec(), Some(blob("d"))),
],
)
.expect("tree");
let base = commit(&repo, full, &[], "base", None);
let paths = vec!["src".to_owned()];
let snap = make_snapshot(&repo, base, &paths, None).expect("snapshot");
let marker = CheckoutMarker {
url: "http://127.0.0.1/demo.git".to_owned(),
id: "0123456789abcdef0123".to_owned(),
branch: "main".to_owned(),
paths,
roots: vec![SnapshotRoot {
commit: snap.commit.to_hex(),
base: base.to_hex(),
}],
landed: Vec::new(),
};
crate::refs::write_ref(repo.git_dir(), "refs/remotes/origin/main", &snap.commit)
.expect("upstream");
Checkout {
_dir: dir,
repo,
snapshot: snap.commit,
marker,
}
}
fn commit(
repo: &Repository,
tree: ObjectId,
parents: &[ObjectId],
msg: &str,
id: Option<&str>,
) -> ObjectId {
let mut s = format!("tree {}\n", tree.to_hex());
for p in parents {
s.push_str(&format!("parent {}\n", p.to_hex()));
}
s.push_str("author A <a@e> 1700000000 +0000\ncommitter A <a@e> 1700000000 +0000\n");
if let Some(id) = id {
s.push_str(&format!("change-id {id}\n"));
}
s.push_str(&format!("\n{msg}\n"));
repo.odb()
.write(ObjectKind::Commit, s.as_bytes())
.expect("commit")
}
impl Checkout {
fn on(&self, parent: ObjectId, msg: &str, id: Option<&str>) -> ObjectId {
let tree = self.read(parent).tree;
commit(&self.repo, tree, &[parent], msg, id)
}
fn read(&self, oid: ObjectId) -> crate::objects::CommitData {
self.repo.odb().read_commit(&oid).expect("read")
}
fn branch(&self, name: &str, tip: ObjectId) {
crate::refs::write_ref(self.repo.git_dir(), &format!("refs/heads/{name}"), &tip)
.expect("branch");
}
fn tip(&self, refname: &str) -> ObjectId {
crate::refs::resolve_ref(self.repo.git_dir(), refname).expect("ref")
}
fn give(&self, only: Option<&[String]>) -> usize {
give_change_ids(&self.repo, &self.marker, only, now()).expect("give ids")
}
fn change(&self, parent: ObjectId, edits: &[(&str, &str)], id: &str) -> ObjectId {
let odb = self.repo.odb();
let edits: Vec<_> = edits
.iter()
.map(|(path, text)| {
let blob = odb.write(ObjectKind::Blob, text.as_bytes()).expect("blob");
(path.as_bytes().to_vec(), Some((0o100644, blob)))
})
.collect();
let tree = edit(odb, Some(self.read(parent).tree), &edits).expect("tree");
commit(&self.repo, tree, &[parent], "change", Some(id))
}
}
#[test]
fn commits_made_with_git_get_the_id_derived_from_them_and_children_follow() {
let co = checkout();
let plain = co.on(co.snapshot, "made with git", None);
let named = co.on(plain, "made with grit", Some(X));
co.branch("work", named);
crate::refs::write_symbolic_ref(co.repo.git_dir(), "HEAD", "refs/heads/work")
.expect("HEAD");
assert_eq!(co.give(None), 1);
let tip = co.tip("refs/heads/work");
let top = co.read(tip);
assert_eq!(change_id(&top).map(|id| id.to_string()), Some(X.to_owned()));
assert_eq!(top.tree, co.read(named).tree, "trees are unchanged");
let below = co.read(top.parents[0]);
assert_eq!(
change_id(&below),
Some(ChangeId::derived_from_commit_id(&plain)),
"the id the commit already reported"
);
assert_eq!(below.parents, vec![co.snapshot]);
assert_eq!(below.message, "made with git\n");
for name in ["refs/heads/work", "HEAD"] {
let log = crate::reflog::read_reflog(co.repo.git_dir(), name).expect("reflog");
assert!(
log.iter()
.any(|e| e.message == REFLOG_MESSAGE
&& e.identity.ends_with(" 1700000000 +0000")),
"{name}: {log:?}"
);
}
assert_eq!(co.give(None), 0);
assert_eq!(co.tip("refs/heads/work"), tip);
assert_eq!(
change_keys(&co.repo, &[tip], &[co.snapshot]).expect("keys"),
vec![
(top.parents[0], ChangeId::derived_from_commit_id(&plain)),
(tip, change_id(&top).expect("id")),
]
);
}
#[test]
fn only_the_named_branches_change_and_signatures_are_dropped() {
let co = checkout();
let tree = co.read(co.snapshot).tree;
let signed = format!(
"tree {}\nparent {}\nauthor A <a@e> 1700000000 +0000\ncommitter A <a@e> 1700000000 +0000\n\
gpgsig -----BEGIN SSH SIGNATURE-----\n abc\n -----END SSH SIGNATURE-----\n\nSigned\n",
tree.to_hex(),
co.snapshot.to_hex()
);
let signed = co
.repo
.odb()
.write(ObjectKind::Commit, signed.as_bytes())
.expect("commit");
let other = co.on(co.snapshot, "other", None);
co.branch("work", signed);
co.branch("other", other);
crate::refs::write_ref(co.repo.git_dir(), "HEAD", &signed).expect("detach");
assert_eq!(co.give(Some(&["work".to_owned()])), 1);
assert_eq!(co.tip("refs/heads/other"), other, "not named");
let tip = co.tip("refs/heads/work");
let bytes = co.repo.odb().read(&tip).expect("read").data;
let text = String::from_utf8_lossy(&bytes);
assert!(!text.contains("gpgsig"), "{text}");
assert!(text.contains("\nchange-id "), "{text}");
assert_eq!(
crate::refs::read_head(co.repo.git_dir()).expect("HEAD"),
None,
"still detached"
);
assert_eq!(co.tip("HEAD"), tip);
}
#[test]
fn remote_tracking_and_landed_history_is_left_alone() {
let mut co = checkout();
let theirs = co.on(co.snapshot, "theirs", None);
crate::refs::write_ref(co.repo.git_dir(), "refs/remotes/origin/shared", &theirs)
.expect("remote");
let mine = co.on(theirs, "mine", None);
co.branch("shared", mine);
let landed = co.on(co.snapshot, "landed", None);
let more = co.on(landed, "more", None);
co.branch("work", more);
co.marker.landed.push(landed.to_hex());
assert_eq!(co.give(None), 2);
assert_eq!(co.tip("refs/remotes/origin/shared"), theirs);
let shared = co.read(co.tip("refs/heads/shared"));
assert_eq!(shared.parents, vec![theirs]);
assert_eq!(
change_id(&shared),
Some(ChangeId::derived_from_commit_id(&mine))
);
let work = co.read(co.tip("refs/heads/work"));
assert_eq!(work.parents, vec![landed]);
assert_eq!(co.tip("refs/remotes/origin/main"), co.snapshot);
}
#[test]
fn nothing_changes_mid_operation_or_with_change_ids_off() {
let co = checkout();
let plain = co.on(co.snapshot, "made with git", None);
co.branch("work", plain);
std::fs::create_dir_all(co.repo.git_dir().join("rebase-merge")).expect("rebase");
assert_eq!(co.give(None), 0);
assert_eq!(co.tip("refs/heads/work"), plain);
std::fs::remove_dir_all(co.repo.git_dir().join("rebase-merge")).expect("done");
std::fs::write(co.repo.git_dir().join("CHERRY_PICK_HEAD"), plain.to_hex()).expect("pick");
assert_eq!(co.give(None), 0);
std::fs::remove_file(co.repo.git_dir().join("CHERRY_PICK_HEAD")).expect("done");
let config = co.repo.git_dir().join("config");
let text = std::fs::read_to_string(&config).expect("config");
let mut file =
crate::config::ConfigFile::parse(&config, &text, crate::config::ConfigScope::Local)
.expect("parse");
file.set("grit.changeIds", "false").expect("set");
file.write().expect("write");
co.repo.reload_config().expect("reload");
assert_eq!(co.give(None), 0);
assert_eq!(co.tip("refs/heads/work"), plain);
}
#[test]
fn nothing_changes_while_another_worktree_is_mid_operation_or_picks_are_paused() {
use crate::config::environment::RepositoryOptions;
use crate::worktree::{add_linked_worktree, AddLinkedWorktreeOptions, WorktreeBranch};
let co = checkout();
let plain = co.on(co.snapshot, "made with git", None);
co.branch("work", plain);
let sequencer = co.repo.git_dir().join("sequencer");
std::fs::create_dir_all(&sequencer).expect("sequencer");
std::fs::write(
sequencer.join("todo"),
format!("pick {} next\n", plain.to_hex()),
)
.expect("todo");
assert_eq!(co.give(None), 0);
std::fs::remove_dir_all(&sequencer).expect("done");
let elsewhere = tempfile::tempdir().expect("tempdir");
let linked = add_linked_worktree(
&co.repo,
&AddLinkedWorktreeOptions {
path: elsewhere.path().join("wt2"),
branch: Some(WorktreeBranch::Detach),
start_point: Some(co.snapshot.to_hex()),
force: false,
lock: None,
no_checkout: true,
track: false,
},
)
.expect("worktree");
std::fs::create_dir_all(linked.admin_dir.join("rebase-merge")).expect("rebase");
assert_eq!(co.give(None), 0, "seen from the main worktree");
std::fs::remove_dir_all(linked.admin_dir.join("rebase-merge")).expect("done");
std::fs::create_dir_all(co.repo.git_dir().join("rebase-merge")).expect("rebase");
let from_linked = Repository::discover(Some(&linked.path), &RepositoryOptions::empty())
.expect("open linked");
let give = || give_change_ids(&from_linked, &co.marker, None, now()).expect("give ids");
assert_eq!(give(), 0, "seen from the linked worktree");
assert_eq!(co.tip("refs/heads/work"), plain);
std::fs::remove_dir_all(co.repo.git_dir().join("rebase-merge")).expect("done");
assert_eq!(give(), 1);
assert_ne!(co.tip("refs/heads/work"), plain);
}
#[test]
fn a_branch_whose_reflog_is_not_utf8_is_given_ids_and_its_log_kept() {
let co = checkout();
let plain = co.on(co.snapshot, "Caf\u{e9}", None);
co.branch("work", plain);
let log = crate::reflog::reflog_path(co.repo.git_dir(), "refs/heads/work");
std::fs::create_dir_all(log.parent().expect("parent")).expect("logs");
let mut written = format!(
"{} {} A <a@e> 1700000000 +0000\tcommit: Caf",
co.snapshot.to_hex(),
plain.to_hex()
)
.into_bytes();
written.extend_from_slice(b"\xe9\n");
std::fs::write(&log, &written).expect("log");
assert_eq!(co.give(None), 1);
assert_ne!(co.tip("refs/heads/work"), plain);
let bytes = std::fs::read(&log).expect("log");
assert!(bytes.starts_with(&written), "the log keeps what it had");
assert!(String::from_utf8_lossy(&bytes[written.len()..]).contains(REFLOG_MESSAGE));
}
#[test]
fn a_copy_counts_only_with_what_its_original_changed_outside_the_paths() {
use crate::snapshot_checkout::{decide_checkin, CheckinDecision, CheckinRule};
let co = checkout();
let base = ObjectId::from_hex(&co.marker.roots[0].base).expect("base");
let paths = co.marker.paths.as_slice();
let keeps = |previous, grafted, targets: &[ObjectId], paths: &[String]| {
keeps_every_commit(&co.repo, previous, grafted, targets, paths).expect("keeps")
};
let amended = co.change(base, &[("src/a", "a2"), ("docs/d", "by bob")], X);
let copy = co.change(base, &[("src/a", "a2")], X);
let more = co.change(copy, &[("src/c", "c")], Y);
assert!(
!keeps(amended, more, &[base], paths),
"docs/d would go back to what the target has"
);
assert_eq!(
decide_checkin(&co.repo, Some(amended), more, &[base], paths, None, false)
.expect("decide"),
CheckinDecision::Diverged
);
assert_eq!(
decide_checkin(&co.repo, Some(amended), more, &[base], paths, None, true)
.expect("decide"),
CheckinDecision::Update {
rule: CheckinRule::Forced
}
);
assert!(keeps(amended, more, &[base], &[]));
let carried = co.change(base, &[("src/a", "a2"), ("docs/d", "by bob")], X);
let on_carried = co.change(carried, &[("src/c", "c")], Y);
assert!(keeps(amended, on_carried, &[base], paths));
let target = co.change(base, &[("docs/d", "by bob")], Z);
let rebased = co.change(target, &[("src/a", "a2")], X);
assert!(keeps(amended, rebased, &[target], paths));
let again = co.change(base, &[("src/a", "a3")], X);
assert!(keeps(copy, again, &[base], paths));
}
#[test]
fn the_server_grafts_a_commit_given_its_id_as_it_grafts_the_commit_without_one() {
let co = checkout();
let odb = co.repo.odb();
let tree = edit(
odb,
Some(co.read(co.snapshot).tree),
&[(
b"src/a".to_vec(),
Some((0o100644, odb.write(ObjectKind::Blob, b"a2").expect("blob"))),
)],
)
.expect("tree");
let plain = commit(&co.repo, tree, &[co.snapshot], "change a", None);
co.branch("work", plain);
let raw = graft(
&co.repo,
&co.marker.roots,
&co.marker.paths,
None,
&[],
plain,
)
.expect("graft")
.expect("grafted");
assert_eq!(co.give(None), 1);
let given = co.tip("refs/heads/work");
assert_ne!(given, plain);
let grafted = graft(
&co.repo,
&co.marker.roots,
&co.marker.paths,
None,
&[],
given,
)
.expect("graft")
.expect("grafted");
assert_eq!(grafted.tip, raw.tip, "the same commit on the server");
assert_eq!(
change_id(&co.read(grafted.tip)),
Some(resolved_change_id(&plain, &co.read(plain)).id)
);
}
}