use itertools::Itertools;
use std::collections::HashMap;
use std::str::FromStr;
use tracing::instrument;
use crate::core::formatting::Pluralize;
use crate::git::FileStatus;
use super::index::{Index, IndexEntry, Stage};
use super::repo::Signature;
use super::status::FileMode;
use super::tree::{hydrate_tree, make_empty_tree};
use super::{
Commit, MaybeZeroOid, NonZeroOid, ReferenceName, Repo, ResolvedReferenceInfo, StatusEntry,
};
const BRANCHLESS_HEAD_TRAILER: &str = "Branchless-head";
const BRANCHLESS_HEAD_REF_TRAILER: &str = "Branchless-head-ref";
const BRANCHLESS_UNSTAGED_TRAILER: &str = "Branchless-unstaged";
#[derive(Clone, Debug)]
pub struct WorkingCopySnapshot<'repo> {
pub base_commit: Commit<'repo>,
pub head_commit: Option<Commit<'repo>>,
pub head_reference_name: Option<ReferenceName>,
pub commit_unstaged: Commit<'repo>,
pub commit_stage0: Commit<'repo>,
pub commit_stage1: Commit<'repo>,
pub commit_stage2: Commit<'repo>,
pub commit_stage3: Commit<'repo>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WorkingCopyChangesType {
None,
Unstaged,
Staged,
Conflicts,
}
impl<'repo> WorkingCopySnapshot<'repo> {
#[instrument]
pub(super) fn create(
repo: &'repo Repo,
index: &Index,
head_info: &ResolvedReferenceInfo,
status_entries: &[StatusEntry],
) -> eyre::Result<Self> {
let head_commit = match head_info.oid {
Some(oid) => Some(repo.find_commit_or_fail(oid)?),
None => None,
};
let head_commit_oid: MaybeZeroOid = match &head_commit {
Some(head_commit) => MaybeZeroOid::NonZero(head_commit.get_oid()),
None => MaybeZeroOid::Zero,
};
let head_reference_name: Option<ReferenceName> = head_info.reference_name.clone();
let commit_unstaged_oid: NonZeroOid = {
Self::create_commit_for_unstaged_changes(repo, head_commit.as_ref(), status_entries)?
};
let commit_stage0 = Self::create_commit_for_stage(
repo,
index,
head_commit.as_ref(),
status_entries,
Stage::Stage0,
)?;
let commit_stage1 = Self::create_commit_for_stage(
repo,
index,
head_commit.as_ref(),
status_entries,
Stage::Stage1,
)?;
let commit_stage2 = Self::create_commit_for_stage(
repo,
index,
head_commit.as_ref(),
status_entries,
Stage::Stage2,
)?;
let commit_stage3 = Self::create_commit_for_stage(
repo,
index,
head_commit.as_ref(),
status_entries,
Stage::Stage3,
)?;
let trailers = {
let mut result = vec![(BRANCHLESS_HEAD_TRAILER, head_commit_oid.to_string())];
if let Some(head_reference_name) = &head_reference_name {
result.push((
BRANCHLESS_HEAD_REF_TRAILER,
head_reference_name.as_str().to_owned(),
));
}
result.extend([
(BRANCHLESS_UNSTAGED_TRAILER, commit_unstaged_oid.to_string()),
(Stage::Stage0.get_trailer(), commit_stage0.to_string()),
(Stage::Stage1.get_trailer(), commit_stage1.to_string()),
(Stage::Stage2.get_trailer(), commit_stage2.to_string()),
(Stage::Stage3.get_trailer(), commit_stage3.to_string()),
]);
result
};
let signature = Signature::automated()?;
let message = format!(
"\
branchless: automated working copy snapshot
{}
",
trailers
.into_iter()
.map(|(name, value)| format!("{name}: {value}"))
.collect_vec()
.join("\n"),
);
let tree = match &head_commit {
Some(head_commit) => head_commit.get_tree()?,
None => make_empty_tree(repo)?,
};
let commit_stage0 = repo.find_commit_or_fail(commit_stage0)?;
let commit_stage1 = repo.find_commit_or_fail(commit_stage1)?;
let commit_stage2 = repo.find_commit_or_fail(commit_stage2)?;
let commit_stage3 = repo.find_commit_or_fail(commit_stage3)?;
let parents = {
let mut parents = vec![
&commit_stage0,
&commit_stage1,
&commit_stage2,
&commit_stage3,
];
if let Some(head_commit) = &head_commit {
parents.insert(0, head_commit);
}
parents
};
let commit_oid =
repo.create_commit(None, &signature, &signature, &message, &tree, parents)?;
Ok(WorkingCopySnapshot {
base_commit: repo.find_commit_or_fail(commit_oid)?,
head_commit: head_commit.clone(),
head_reference_name,
commit_unstaged: repo.find_commit_or_fail(commit_unstaged_oid)?,
commit_stage0,
commit_stage1,
commit_stage2,
commit_stage3,
})
}
#[instrument]
pub fn try_from_base_commit<'a>(
repo: &'repo Repo,
base_commit: &'a Commit<'repo>,
) -> eyre::Result<Option<WorkingCopySnapshot<'repo>>> {
let trailers = base_commit.get_trailers()?;
let find_commit = |trailer: &str| -> eyre::Result<Option<Commit>> {
for (k, v) in trailers.iter() {
if k != trailer {
continue;
}
let oid = MaybeZeroOid::from_str(v);
let oid = match oid {
Ok(MaybeZeroOid::NonZero(oid)) => oid,
Ok(MaybeZeroOid::Zero) => return Ok(None),
Err(_) => continue,
};
let result = repo.find_commit_or_fail(oid)?;
return Ok(Some(result));
}
Ok(None)
};
let head_commit = find_commit(BRANCHLESS_HEAD_TRAILER)?;
let commit_unstaged = match find_commit(BRANCHLESS_UNSTAGED_TRAILER)? {
Some(commit) => commit,
None => return Ok(None),
};
let head_reference_name = trailers.iter().find_map(|(k, v)| {
if k == BRANCHLESS_HEAD_REF_TRAILER {
Some(ReferenceName::from(v.as_str()))
} else {
None
}
});
let commit_stage0 = match find_commit(Stage::Stage0.get_trailer())? {
Some(commit) => commit,
None => return Ok(None),
};
let commit_stage1 = match find_commit(Stage::Stage1.get_trailer())? {
Some(commit) => commit,
None => return Ok(None),
};
let commit_stage2 = match find_commit(Stage::Stage2.get_trailer())? {
Some(commit) => commit,
None => return Ok(None),
};
let commit_stage3 = match find_commit(Stage::Stage3.get_trailer())? {
Some(commit) => commit,
None => return Ok(None),
};
Ok(Some(WorkingCopySnapshot {
base_commit: base_commit.to_owned(),
head_commit,
head_reference_name,
commit_unstaged,
commit_stage0,
commit_stage1,
commit_stage2,
commit_stage3,
}))
}
#[instrument]
fn create_commit_for_unstaged_changes(
repo: &Repo,
head_commit: Option<&Commit>,
status_entries: &[StatusEntry],
) -> eyre::Result<NonZeroOid> {
let changed_paths: Vec<_> = status_entries
.iter()
.cloned()
.filter(|entry| {
entry.working_copy_status.is_changed() || entry.index_status.is_changed()
})
.flat_map(|entry| {
entry
.paths()
.into_iter()
.map(move |path| (path, entry.working_copy_file_mode))
})
.collect();
let num_changes = changed_paths.len();
let head_tree = head_commit.map(|commit| commit.get_tree()).transpose()?;
let hydrate_entries = {
let mut result = HashMap::new();
for (path, file_mode) in changed_paths {
let entry = if file_mode == FileMode::Unreadable {
None
} else {
repo.create_blob_from_path(&path)?
.map(|blob_oid| (blob_oid, file_mode))
};
result.insert(path, entry);
}
result
};
let tree_unstaged = {
let tree_oid = hydrate_tree(repo, head_tree.as_ref(), hydrate_entries)?;
repo.find_tree_or_fail(tree_oid)?
};
let signature = Signature::automated()?;
let message = format!(
"branchless: working copy snapshot data: {}",
Pluralize {
determiner: None,
amount: num_changes,
unit: ("unstaged change", "unstaged changes"),
}
);
let commit = repo.create_commit(
None,
&signature,
&signature,
&message,
&tree_unstaged,
Vec::from_iter(head_commit),
)?;
Ok(commit)
}
#[instrument]
fn create_commit_for_stage(
repo: &Repo,
index: &Index,
head_commit: Option<&Commit>,
status_entries: &[StatusEntry],
stage: Stage,
) -> eyre::Result<NonZeroOid> {
let mut updated_entries = HashMap::new();
for StatusEntry {
path, index_status, ..
} in status_entries
{
let index_entry = index.get_entry_in_stage(path, stage);
let entry = match index_entry {
None => match (stage, index_status) {
(Stage::Stage0, _) => None,
(Stage::Stage1 | Stage::Stage2 | Stage::Stage3, FileStatus::Unmerged) => None,
(
Stage::Stage1 | Stage::Stage2 | Stage::Stage3,
FileStatus::Added
| FileStatus::Copied
| FileStatus::Deleted
| FileStatus::Ignored
| FileStatus::Modified
| FileStatus::Renamed
| FileStatus::Unmodified
| FileStatus::Untracked,
) => continue,
},
Some(IndexEntry {
oid: MaybeZeroOid::Zero,
file_mode: _,
}) => None,
Some(IndexEntry {
oid: MaybeZeroOid::NonZero(oid),
file_mode,
}) => Some((oid, file_mode)),
};
updated_entries.insert(path.clone(), entry);
}
let num_stage_changes = updated_entries.len();
let head_tree = match head_commit {
Some(head_commit) => Some(head_commit.get_tree()?),
None => None,
};
let tree_oid = hydrate_tree(repo, head_tree.as_ref(), updated_entries)?;
let tree = repo.find_tree_or_fail(tree_oid)?;
let signature = Signature::automated()?;
let message = format!(
"branchless: working copy snapshot data: {}",
Pluralize {
determiner: None,
amount: num_stage_changes,
unit: (
&format!("change in stage {}", i32::from(stage)),
&format!("changes in stage {}", i32::from(stage)),
),
}
);
let commit_oid = repo.create_commit(
None,
&signature,
&signature,
&message,
&tree,
match head_commit {
Some(parent_commit) => vec![parent_commit],
None => vec![],
},
)?;
Ok(commit_oid)
}
#[instrument]
pub fn get_working_copy_changes_type(&self) -> eyre::Result<WorkingCopyChangesType> {
let base_tree = self.base_commit.get_tree()?;
let base_oid = base_tree.get_oid();
let unstaged_tree = self.commit_unstaged.get_tree()?;
let stage0_tree = self.commit_stage0.get_tree()?;
let stage1_tree = self.commit_stage1.get_tree()?;
let stage2_tree = self.commit_stage2.get_tree()?;
let stage3_tree = self.commit_stage3.get_tree()?;
if base_oid != stage1_tree.get_oid()
|| base_oid != stage2_tree.get_oid()
|| base_oid != stage3_tree.get_oid()
{
Ok(WorkingCopyChangesType::Conflicts)
} else if base_oid != stage0_tree.get_oid() {
Ok(WorkingCopyChangesType::Staged)
} else if base_oid != unstaged_tree.get_oid() {
Ok(WorkingCopyChangesType::Unstaged)
} else {
Ok(WorkingCopyChangesType::None)
}
}
}
#[cfg(test)]
mod tests {
use std::time::SystemTime;
use crate::core::effects::Effects;
use crate::core::eventlog::EventLogDb;
use crate::core::formatting::Glyphs;
use crate::git::WorkingCopyChangesType;
use crate::testing::{make_git, GitRunOptions};
#[test]
fn test_has_conflicts() -> eyre::Result<()> {
let git = make_git()?;
git.init_repo()?;
git.commit_file("test1", 1)?;
git.commit_file("test2", 2)?;
git.run(&["checkout", "HEAD^"])?;
git.commit_file_with_contents("test2", 2, "conflicting contents")?;
git.run_with_options(
&["merge", "master"],
&GitRunOptions {
expected_exit_code: 1,
..Default::default()
},
)?;
let glyphs = Glyphs::text();
let effects = Effects::new_suppress_for_test(glyphs);
let git_run_info = git.get_git_run_info();
let repo = git.get_repo()?;
let index = repo.get_index()?;
let conn = repo.get_db_conn()?;
let event_log_db = EventLogDb::new(&conn)?;
let event_tx_id = event_log_db.make_transaction_id(SystemTime::now(), "testing")?;
let head_info = repo.get_head_info()?;
let (snapshot, status) = repo.get_status(
&effects,
&git_run_info,
&index,
&head_info,
Some(event_tx_id),
)?;
insta::assert_debug_snapshot!(status, @r###"
[
StatusEntry {
index_status: Unmerged,
working_copy_status: Added,
working_copy_file_mode: Blob,
path: "test2.txt",
orig_path: None,
},
]
"###);
assert_eq!(
snapshot.get_working_copy_changes_type()?,
WorkingCopyChangesType::Conflicts
);
Ok(())
}
}