use crate::ops::PackageOpts;
use crate::sources::PathEntry;
use crate::workspace::{Package, Workspace};
use crate::{CargoResult, GlobalContext};
use anyhow::Context;
use cargo_util::paths;
use cargo_util_terminal::report::Level;
use gix::bstr::BString;
use gix::bstr::ByteSlice;
use gix::bstr::ByteVec;
use gix::dir::walk::EmissionMode;
use gix::dirwalk::Options;
use gix::index::entry::Mode;
use gix::status::tree_index::TrackRenames;
use gix::worktree::stack::state::ignore::Source;
use serde::Serialize;
use std::path::{Path, PathBuf};
use tracing::debug;
#[derive(Serialize)]
pub struct VcsInfo {
git: GitVcsInfo,
path_in_vcs: String,
}
#[derive(Serialize)]
pub struct GitVcsInfo {
sha1: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
}
#[tracing::instrument(skip_all)]
pub fn check_repo_state(
p: &Package,
src_files: &[PathEntry],
ws: &Workspace<'_>,
opts: &PackageOpts<'_>,
) -> CargoResult<Option<VcsInfo>> {
let gctx = ws.gctx();
let Ok(mut repo) = gix::discover(p.root()) else {
gctx.shell().verbose(|shell| {
shell.warn(format_args!(
"no (git) VCS found for `{}`",
p.root().display()
))
})?;
return Ok(None);
};
let Some(workdir) = repo.workdir() else {
debug!(
"no (git) workdir found for repo at `{}`",
repo.path().display()
);
return Ok(None);
};
debug!("found a git repo at `{}`", workdir.display());
let path = p.manifest_path();
let manifest_exists = path.exists();
let path = paths::strip_prefix_canonical(path, workdir).unwrap_or_else(|_| path.to_path_buf());
let rela_path =
gix::path::to_unix_separators_on_windows(gix::path::os_str_into_bstr(path.as_os_str())?);
if !manifest_exists {
gctx.shell().verbose(|shell| {
shell.warn(format_args!(
"Cargo.toml not found at `{}` in workdir `{}`",
path.display(),
workdir.display()
))
})?;
return Ok(None);
};
let manifest_is_ignored = {
let index = repo.index_or_empty()?;
let mut excludes =
repo.excludes(&index, None, Source::WorktreeThenIdMappingIfNotSkipped)?;
excludes
.at_entry(rela_path.as_bstr(), Some(Mode::FILE))?
.is_excluded()
};
if manifest_is_ignored {
gctx.shell().verbose(|shell| {
shell.warn(format_args!(
"found (git) Cargo.toml ignored at `{}` in workdir `{}`",
path.display(),
workdir.display()
))
})?;
return Ok(None);
}
warn_symlink_checked_out_as_plain_text_file(gctx, src_files, &repo)?;
debug!(
"found (git) Cargo.toml at `{}` in workdir `{}`",
path.display(),
workdir.display(),
);
let Some(git) = git(ws, p, src_files, &mut repo, &opts)? else {
return Ok(None);
};
let path_in_vcs = path
.parent()
.and_then(|p| p.to_str())
.unwrap_or("")
.replace("\\", "/");
Ok(Some(VcsInfo { git, path_in_vcs }))
}
fn warn_symlink_checked_out_as_plain_text_file(
gctx: &GlobalContext,
src_files: &[PathEntry],
repo: &gix::Repository,
) -> CargoResult<()> {
if repo
.config_snapshot()
.boolean(&gix::config::tree::Core::SYMLINKS)
.unwrap_or(true)
{
return Ok(());
}
if src_files.iter().any(|f| f.maybe_plain_text_symlink()) {
let msg = format!(
"found symbolic links that may be checked out as regular files for git repo at `{}/`",
repo.workdir().unwrap().display()
);
let mut notes = vec![
Level::NOTE.message(
"this might cause the `.crate` file to include incorrect or incomplete files",
),
Level::HELP.message("to avoid this, set the Git config `core.symlinks` to `true`"),
];
if cfg!(windows) {
notes.push(
Level::HELP.message("on Windows, enable the Developer Mode to support symlinks"),
);
};
gctx.shell().print_report(
&[Level::WARNING
.secondary_title(msg)
.elements(notes.into_iter())],
false,
)?;
}
Ok(())
}
fn git(
ws: &Workspace<'_>,
pkg: &Package,
src_files: &[PathEntry],
repo: &mut gix::Repository,
opts: &PackageOpts<'_>,
) -> CargoResult<Option<GitVcsInfo>> {
{
let mut config = repo.config_snapshot_mut();
config.set_value(&gix::config::tree::Index::THREADS, "false")?;
}
let mut dirty_files = Vec::new();
let workdir = repo.workdir().unwrap();
collect_statuses(
repo,
workdir,
relative_package_root(repo, pkg.root()).as_deref(),
&mut dirty_files,
)?;
status_submodules(repo, &mut dirty_files)?;
let cwd = ws.gctx().cwd();
let mut dirty_src_files: Vec<_> = src_files
.iter()
.filter(|src_file| {
if let Some(canon_src_file) = src_file.is_symlink_or_under_symlink().then(|| {
gix::path::realpath_opts(
&src_file,
ws.gctx().cwd(),
gix::path::realpath::MAX_SYMLINKS,
)
.unwrap_or_else(|_| src_file.to_path_buf())
}) {
dirty_files
.iter()
.any(|path| canon_src_file.starts_with(path))
} else {
dirty_files.iter().any(|path| src_file.starts_with(path))
}
})
.map(|p| p.as_ref())
.chain(dirty_files_outside_pkg_root(ws, pkg, repo, src_files)?.iter())
.map(|path| {
pathdiff::diff_paths(path, cwd)
.as_ref()
.unwrap_or(path)
.display()
.to_string()
})
.collect();
let dirty = !dirty_src_files.is_empty();
if !dirty || opts.allow_dirty {
let maybe_head_id = repo.head()?.try_peel_to_id()?;
Ok(maybe_head_id.map(|id| GitVcsInfo {
sha1: id.to_string(),
dirty,
}))
} else {
dirty_src_files.sort_unstable();
anyhow::bail!(
"{} files in the working directory contain changes that were \
not yet committed into git:\n\n{}\n\n\
to proceed despite this and include the uncommitted changes, pass the `--allow-dirty` flag",
dirty_src_files.len(),
dirty_src_files.join("\n")
)
}
}
#[must_use]
fn collect_statuses(
repo: &gix::Repository,
workdir: &Path,
relative_package_root: Option<&Path>,
dirty_files: &mut Vec<PathBuf>,
) -> CargoResult<()> {
let statuses = repo
.status(gix::progress::Discard)?
.dirwalk_options(configure_dirwalk)
.tree_index_track_renames(TrackRenames::Disabled)
.index_worktree_submodules(None)
.into_iter(
relative_package_root.map(|rela_pkg_root| {
let mut pathspec = BString::from(":(top)");
let prefix =
gix::path::to_unix_separators_on_windows(gix::path::into_bstr(rela_pkg_root));
pathspec.push_str(prefix.as_ref());
pathspec
}),
)
.with_context(|| {
format!(
"failed to begin git status for repo {}",
repo.path().display()
)
})?;
for status in statuses {
let status = status.with_context(|| {
format!(
"failed to retrieve git status from repo {}",
repo.path().display()
)
})?;
let rel_path = gix::path::from_bstr(status.location());
let path = workdir.join(&rel_path);
if path.ends_with("Cargo.lock")
&& matches!(
&status,
gix::status::Item::IndexWorktree(
gix::status::index_worktree::Item::DirectoryContents { entry, .. }
) if matches!(entry.status, gix::dir::entry::Status::Ignored(_))
)
{
continue;
}
dirty_files.push(path);
}
Ok(())
}
fn status_submodules(repo: &gix::Repository, dirty_files: &mut Vec<PathBuf>) -> CargoResult<()> {
let Some(submodules) = repo.submodules()? else {
return Ok(());
};
for submodule in submodules {
if let Some(sub_repo) = submodule.open()? {
let Some(workdir) = sub_repo.workdir() else {
continue;
};
status_submodules(&sub_repo, dirty_files)?;
collect_statuses(&sub_repo, workdir, None, dirty_files)?;
}
}
Ok(())
}
fn relative_package_root(repo: &gix::Repository, pkg_root: &Path) -> Option<PathBuf> {
let workdir = repo.workdir().unwrap();
let rela_root = pkg_root.strip_prefix(workdir).unwrap_or(Path::new(""));
if rela_root.as_os_str().is_empty() {
None
} else {
rela_root.to_owned().into()
}
}
fn dirty_files_outside_pkg_root(
ws: &Workspace<'_>,
pkg: &Package,
repo: &gix::Repository,
src_files: &[PathEntry],
) -> CargoResult<Vec<PathBuf>> {
let pkg_root = pkg.root();
let workdir = repo.workdir().unwrap();
let meta = pkg.manifest().metadata();
let metadata_paths: Vec<_> = [&meta.license_file, &meta.readme]
.into_iter()
.filter_map(|p| p.as_deref())
.map(|path| paths::normalize_path(&pkg_root.join(path)))
.collect();
let linked_files_outside_package_root: Vec<_> = src_files
.iter()
.filter(|p| p.is_symlink_or_under_symlink())
.map(|p| p.as_ref().as_path())
.chain(metadata_paths.iter().map(AsRef::as_ref))
.chain([ws.root_manifest()])
.filter(|p| paths::strip_prefix_canonical(p, pkg_root).is_err())
.filter_map(|p| paths::strip_prefix_canonical(p, workdir).ok())
.collect();
if linked_files_outside_package_root.is_empty() {
return Ok(Vec::new());
}
let statuses = repo
.status(gix::progress::Discard)?
.dirwalk_options(configure_dirwalk)
.index_worktree_options_mut(|opts| opts.thread_limit = Some(1))
.tree_index_track_renames(TrackRenames::Disabled)
.index_worktree_submodules(None)
.into_iter(
linked_files_outside_package_root
.into_iter()
.map(|p| gix::path::into_bstr(p).into_owned()),
)
.with_context(|| {
format!(
"failed to begin git status for outfor repo {}",
repo.path().display()
)
})?;
let mut dirty_files = Vec::new();
for status in statuses {
let status = status.with_context(|| {
format!(
"failed to retrieve git status from repo {}",
repo.path().display()
)
})?;
let rel_path = gix::path::from_bstr(status.location());
let path = workdir.join(&rel_path);
dirty_files.push(path);
}
Ok(dirty_files)
}
fn configure_dirwalk(opts: Options) -> Options {
opts.emit_untracked(gix::dir::walk::EmissionMode::Matching)
.emit_ignored(Some(EmissionMode::CollapseDirectory))
.emit_tracked(false)
.recurse_repositories(false)
.symlinks_to_directories_are_ignored_like_directories(true)
.emit_empty_directories(false)
}