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()
.filter(|entry| {
entry.working_copy_status.is_changed() || entry.index_status.is_changed()
})
.flat_map(|entry| {
entry
.paths()
.into_iter()
.map(|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_oid = self.base_commit.get_tree_oid();
let unstaged_tree_oid = self.commit_unstaged.get_tree_oid();
let stage0_tree_oid = self.commit_stage0.get_tree_oid();
let stage1_tree_oid = self.commit_stage1.get_tree_oid();
let stage2_tree_oid = self.commit_stage2.get_tree_oid();
let stage3_tree_oid = self.commit_stage3.get_tree_oid();
if base_tree_oid != stage1_tree_oid
|| base_tree_oid != stage2_tree_oid
|| base_tree_oid != stage3_tree_oid
{
Ok(WorkingCopyChangesType::Conflicts)
} else if base_tree_oid != stage0_tree_oid {
Ok(WorkingCopyChangesType::Staged)
} else if base_tree_oid != unstaged_tree_oid {
Ok(WorkingCopyChangesType::Unstaged)
} else {
Ok(WorkingCopyChangesType::None)
}
}
}