Provide a package implementation with gix-status.

This should also help fixing these spurious "cannot package because
some excluded file is untracked" issues.

Remove the respective `git2` implementation at the same time
as there seems to be no need for it.
This commit is contained in:
Sebastian Thiel 2025-07-23 04:36:07 +02:00
parent ac04a82c7f
commit d3b85cd96a
No known key found for this signature in database
GPG Key ID: 9CB5EE7895E8268B
6 changed files with 286 additions and 143 deletions

74
Cargo.lock generated
View File

@ -892,6 +892,20 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "dashmap"
version = "6.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "der"
version = "0.7.10"
@ -1283,6 +1297,7 @@ dependencies = [
"gix-revwalk",
"gix-sec",
"gix-shallow",
"gix-status",
"gix-submodule",
"gix-tempfile",
"gix-trace",
@ -1445,8 +1460,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de854852010d44a317f30c92d67a983e691c9478c8a3fb4117c1f48626bcdea8"
dependencies = [
"bstr",
"gix-attributes",
"gix-command",
"gix-filter",
"gix-fs",
"gix-hash",
"gix-index",
"gix-object",
"gix-path",
"gix-pathspec",
"gix-tempfile",
"gix-trace",
"gix-traverse",
"gix-worktree",
"imara-diff",
"thiserror 2.0.12",
]
@ -1573,7 +1600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c35300b54896153e55d53f4180460931ccd69b7e8d2f6b9d6401122cdedc4f07"
dependencies = [
"gix-hash",
"hashbrown",
"hashbrown 0.15.4",
"parking_lot",
]
@ -1609,7 +1636,7 @@ dependencies = [
"gix-traverse",
"gix-utils",
"gix-validate",
"hashbrown",
"hashbrown 0.15.4",
"itoa 1.0.15",
"libc",
"memmap2",
@ -1902,6 +1929,29 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "gix-status"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4afff9b34eeececa8bdc32b42fb318434b6b1391d9f8d45fe455af08dc2d35"
dependencies = [
"bstr",
"filetime",
"gix-diff",
"gix-dir",
"gix-features",
"gix-filter",
"gix-fs",
"gix-hash",
"gix-index",
"gix-object",
"gix-path",
"gix-pathspec",
"gix-worktree",
"portable-atomic",
"thiserror 2.0.12",
]
[[package]]
name = "gix-submodule"
version = "0.20.0"
@ -1923,6 +1973,7 @@ version = "18.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "666c0041bcdedf5fa05e9bef663c897debab24b7dc1741605742412d1d47da57"
dependencies = [
"dashmap",
"gix-fs",
"libc",
"once_cell",
@ -2092,6 +2143,12 @@ dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.4"
@ -2109,7 +2166,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown",
"hashbrown 0.15.4",
]
[[package]]
@ -2323,6 +2380,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "imara-diff"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17d34b7d42178945f775e84bc4c36dde7c1c6cdfea656d3354d009056f2bb3d2"
dependencies = [
"hashbrown 0.15.4",
]
[[package]]
name = "indexmap"
version = "2.10.0"
@ -2330,7 +2396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.15.4",
]
[[package]]

View File

@ -49,7 +49,7 @@ flate2 = { version = "1.1.2", default-features = false, features = ["zlib-rs"] }
git2 = "0.20.2"
git2-curl = "0.21.0"
# When updating this, also see if `gix-transport` further down needs updating or some auth-related tests will fail.
gix = { version = "0.73.0", default-features = false, features = ["blocking-http-transport-curl", "progress-tree", "parallel", "dirwalk"] }
gix = { version = "0.73.0", default-features = false, features = ["blocking-http-transport-curl", "progress-tree", "parallel", "dirwalk", "status"] }
glob = "0.3.2"
# Pinned due to https://github.com/sunng87/handlebars-rust/issues/711
handlebars = { version = "=6.3.1", features = ["dir_source"] }

View File

@ -480,7 +480,6 @@ fn prepare_archive(
// Check (git) repository state, getting the current commit hash.
let vcs_info = vcs::check_repo_state(pkg, &src_files, ws, &opts)?;
build_ar_list(ws, pkg, src_files, vcs_info, opts.include_lockfile)
}

View File

@ -1,21 +1,19 @@
//! Helpers to gather the VCS information for `cargo package`.
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use anyhow::Context as _;
use cargo_util::paths;
use serde::Serialize;
use tracing::debug;
use crate::CargoResult;
use crate::GlobalContext;
use crate::core::Package;
use crate::core::Workspace;
use crate::core::{Package, Workspace};
use crate::ops::PackageOpts;
use crate::sources::PathEntry;
use super::PackageOpts;
use crate::{CargoResult, GlobalContext};
use anyhow::Context;
use cargo_util::paths;
use gix::bstr::ByteSlice;
use gix::dir::walk::EmissionMode;
use gix::index::entry::Mode;
use gix::status::tree_index::TrackRenames;
use gix::worktree::stack::state::ignore::Source;
use serde::Serialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tracing::debug;
/// Represents the VCS information when packaging.
#[derive(Serialize)]
@ -29,7 +27,7 @@ pub struct VcsInfo {
#[derive(Serialize)]
pub struct GitVcsInfo {
sha1: String,
/// Indicate whether or not the Git worktree is dirty.
/// Indicate whether the Git worktree is dirty.
#[serde(skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
}
@ -39,7 +37,7 @@ pub struct GitVcsInfo {
/// If *git*, and the source is *dirty* (e.g., has uncommitted changes),
/// and `--allow-dirty` has not been passed,
/// then `bail!` with an informative message.
/// Otherwise return the sha1 hash of the current *HEAD* commit,
/// Otherwise, return the sha1 hash of the current *HEAD* commit,
/// or `None` if no repo is found.
#[tracing::instrument(skip_all)]
pub fn check_repo_state(
@ -49,7 +47,7 @@ pub fn check_repo_state(
opts: &PackageOpts<'_>,
) -> CargoResult<Option<VcsInfo>> {
let gctx = ws.gctx();
let Ok(repo) = git2::Repository::discover(p.root()) else {
let Ok(repo) = gix::discover(p.root()) else {
gctx.shell().verbose(|shell| {
shell.warn(format_args!(
"no (git) VCS found for `{}`",
@ -71,21 +69,33 @@ pub fn check_repo_state(
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 Ok(status) = repo.status_file(&path) else {
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!(
"no (git) Cargo.toml found at `{}` in workdir `{}`",
"Cargo.toml not found at `{}` in workdir `{}`",
path.display(),
workdir.display()
))
})?;
// No checked-in `Cargo.toml` found. This package may be irrelevant.
// No `Cargo.toml` found. This package may be irrelevant.
// Have to assume it is clean.
return Ok(None);
};
if !(status & git2::Status::IGNORED).is_empty() {
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 `{}`",
@ -106,7 +116,7 @@ pub fn check_repo_state(
workdir.display(),
);
let Some(git) = git(ws, p, src_files, &repo, &opts)? else {
// If the git repo lacks essensial field like `sha1`, and since this field exists from the beginning,
// If the git repo lacks essential field like `sha1`, and since this field exists from the beginning,
// then don't generate the corresponding file in order to maintain consistency with past behavior.
return Ok(None);
};
@ -117,7 +127,7 @@ pub fn check_repo_state(
.unwrap_or("")
.replace("\\", "/");
return Ok(Some(VcsInfo { git, path_in_vcs }));
Ok(Some(VcsInfo { git, path_in_vcs }))
}
/// Warns if any symlinks were checked out as plain text files.
@ -136,11 +146,11 @@ pub fn check_repo_state(
fn warn_symlink_checked_out_as_plain_text_file(
gctx: &GlobalContext,
src_files: &[PathEntry],
repo: &git2::Repository,
repo: &gix::Repository,
) -> CargoResult<()> {
if repo
.config()
.and_then(|c| c.get_bool("core.symlinks"))
.config_snapshot()
.boolean(&gix::config::tree::Core::SYMLINKS)
.unwrap_or(true)
{
return Ok(());
@ -149,8 +159,8 @@ fn warn_symlink_checked_out_as_plain_text_file(
if src_files.iter().any(|f| f.maybe_plain_text_symlink()) {
let mut shell = gctx.shell();
shell.warn(format_args!(
"found symbolic links that may be checked out as regular files for git repo at `{}`\n\
This might cause the `.crate` file to include incorrect or incomplete files",
"found symbolic links that may be checked out as regular files for git repo at `{}/`\n\
This might cause the `.crate` file to include incorrect or incomplete files",
repo.workdir().unwrap().display(),
))?;
let extra_note = if cfg!(windows) {
@ -171,7 +181,7 @@ fn git(
ws: &Workspace<'_>,
pkg: &Package,
src_files: &[PathEntry],
repo: &git2::Repository,
repo: &gix::Repository,
opts: &PackageOpts<'_>,
) -> CargoResult<Option<GitVcsInfo>> {
// This is a collection of any dirty or untracked files. This covers:
@ -179,13 +189,23 @@ fn git(
// - untracked files (which are "new" worktree files)
// - ignored (in case the user has an `include` directive that
// conflicts with .gitignore).
let mut dirty_files = Vec::new();
let pathspec = relative_pathspec(repo, pkg.root());
collect_statuses(repo, &[pathspec.as_str()], &mut dirty_files)?;
let (mut dirty_files, mut dirty_files_outside_package_root) = (Vec::new(), Vec::new());
let workdir = repo.workdir().unwrap();
collect_statuses(
repo,
workdir,
relative_package_root(repo, pkg.root()).as_deref(),
&mut dirty_files,
&mut dirty_files_outside_package_root,
)?;
// Include each submodule so that the error message can provide
// specifically *which* files in a submodule are modified.
status_submodules(repo, &mut dirty_files)?;
status_submodules(
repo,
&mut dirty_files,
&mut dirty_files_outside_package_root,
)?;
// Find the intersection of dirty in git, and the src_files that would
// be packaged. This is a lazy n^2 check, but seems fine with
@ -193,9 +213,27 @@ fn git(
let cwd = ws.gctx().cwd();
let mut dirty_src_files: Vec<_> = src_files
.iter()
.filter(|src_file| dirty_files.iter().any(|path| src_file.starts_with(path)))
.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())
.chain(
dirty_files_outside_pkg_root(ws, pkg, &dirty_files_outside_package_root, src_files)?
.iter(),
)
.map(|path| {
pathdiff::diff_paths(path, cwd)
.as_ref()
@ -206,14 +244,9 @@ fn git(
.collect();
let dirty = !dirty_src_files.is_empty();
if !dirty || opts.allow_dirty {
// Must check whetherthe repo has no commit firstly, otherwise `revparse_single` would fail on bare commit repo.
// Due to lacking the `sha1` field, it's better not record the `GitVcsInfo` for consistency.
if repo.is_empty()? {
return Ok(None);
}
let rev_obj = repo.revparse_single("HEAD")?;
Ok(Some(GitVcsInfo {
sha1: rev_obj.id().to_string(),
let maybe_head_id = repo.head()?.try_peel_to_id_in_place()?;
Ok(maybe_head_id.map(|id| GitVcsInfo {
sha1: id.to_string(),
dirty,
}))
} else {
@ -228,6 +261,114 @@ fn git(
}
}
/// Helper to collect dirty statuses for a single repo.
/// `relative_package_root` is `Some` if the root is a sub-directory of the workdir.
/// Writes dirty files outside `relative_package_root` into `dirty_files_outside_package_root`,
/// and all *everything else* into `dirty_files`.
#[must_use]
fn collect_statuses(
repo: &gix::Repository,
workdir: &Path,
relative_package_root: Option<&Path>,
dirty_files: &mut Vec<PathBuf>,
dirty_files_outside_package_root: &mut Vec<PathBuf>,
) -> CargoResult<()> {
let statuses = repo
.status(gix::progress::Discard)?
.dirwalk_options(|opts| {
opts.emit_untracked(gix::dir::walk::EmissionMode::Matching)
// Also pick up ignored files or whole directories
// to specifically catch overzealously ignored source files.
// Later we will match these dirs by prefix, which is why collapsing
// them is desirable here.
.emit_ignored(Some(EmissionMode::CollapseDirectory))
.emit_tracked(false)
.recurse_repositories(false)
.symlinks_to_directories_are_ignored_like_directories(true)
.emit_empty_directories(false)
})
.tree_index_track_renames(TrackRenames::Disabled)
.index_worktree_submodules(None)
.into_iter(None /* pathspec patterns */)
.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 relative_package_root.is_some_and(|pkg_root| !rel_path.starts_with(pkg_root)) {
dirty_files_outside_package_root.push(path);
continue;
}
// It is OK to include Cargo.lock even if it is ignored.
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(())
}
/// Helper to collect dirty statuses while recursing into submodules.
fn status_submodules(
repo: &gix::Repository,
dirty_files: &mut Vec<PathBuf>,
dirty_files_outside_package_root: &mut Vec<PathBuf>,
) -> CargoResult<()> {
let Some(submodules) = repo.submodules()? else {
return Ok(());
};
for submodule in submodules {
// Ignore submodules that don't open, they are probably not initialized.
// If its files are required, then the verification step should fail.
if let Some(sub_repo) = submodule.open()? {
let Some(workdir) = sub_repo.workdir() else {
continue;
};
status_submodules(&sub_repo, dirty_files, dirty_files_outside_package_root)?;
collect_statuses(
&sub_repo,
workdir,
None,
dirty_files,
dirty_files_outside_package_root,
)?;
}
}
Ok(())
}
/// Make `pkg_root` relative to the `repo` workdir.
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()
}
}
/// Checks whether "included" source files outside package root have been modified.
///
/// This currently looks at
@ -242,14 +383,10 @@ fn git(
fn dirty_files_outside_pkg_root(
ws: &Workspace<'_>,
pkg: &Package,
repo: &git2::Repository,
dirty_files_outside_of_package_root: &[PathBuf],
src_files: &[PathEntry],
) -> CargoResult<HashSet<PathBuf>> {
let pkg_root = pkg.root();
let workdir = repo.workdir().unwrap();
let mut dirty_files = HashSet::new();
let meta = pkg.manifest().metadata();
let metadata_paths: Vec<_> = [&meta.license_file, &meta.readme]
.into_iter()
@ -257,7 +394,7 @@ fn dirty_files_outside_pkg_root(
.map(|path| paths::normalize_path(&pkg_root.join(path)))
.collect();
for rel_path in src_files
let dirty_files = src_files
.iter()
.filter(|p| p.is_symlink_or_under_symlink())
.map(|p| p.as_ref().as_path())
@ -266,83 +403,19 @@ fn dirty_files_outside_pkg_root(
// If inside package root. Don't bother checking git status.
.filter(|p| paths::strip_prefix_canonical(p, pkg_root).is_err())
// Handle files outside package root but under git workdir,
.filter_map(|p| paths::strip_prefix_canonical(p, workdir).ok())
{
match repo.status_file(&rel_path) {
Ok(git2::Status::CURRENT) => {}
Ok(_) => {
dirty_files.insert(workdir.join(rel_path));
}
Err(e) => {
// Dirtiness check for symlinks is mostly informational.
// And changes in submodule would fail git-status as well (see #15384).
// To avoid adding complicated logic to handle that,
// for now we ignore the status check failure.
debug!(
"failed to get status from file `{}` in git repo at `{}`: {e}",
rel_path.display(),
workdir.display()
);
}
}
}
.filter_map(|src_file| {
let canon_src_path = gix::path::realpath_opts(
src_file,
ws.gctx().cwd(),
gix::path::realpath::MAX_SYMLINKS,
)
.unwrap_or_else(|_| src_file.to_owned());
dirty_files_outside_of_package_root
.iter()
.any(|p| canon_src_path.starts_with(p))
.then_some(canon_src_path)
})
.collect();
Ok(dirty_files)
}
/// Helper to collect dirty statuses for a single repo.
fn collect_statuses(
repo: &git2::Repository,
pathspecs: &[&str],
dirty_files: &mut Vec<PathBuf>,
) -> CargoResult<()> {
let mut status_opts = git2::StatusOptions::new();
// Exclude submodules, as they are being handled manually by recursing
// into each one so that details about specific files can be
// retrieved.
pathspecs
.iter()
.fold(&mut status_opts, git2::StatusOptions::pathspec)
.exclude_submodules(true)
.include_ignored(true)
.include_untracked(true);
let repo_statuses = repo.statuses(Some(&mut status_opts)).with_context(|| {
format!(
"failed to retrieve git status from repo {}",
repo.path().display()
)
})?;
let workdir = repo.workdir().unwrap();
let this_dirty = repo_statuses.iter().filter_map(|entry| {
let path = entry.path().expect("valid utf-8 path");
if path.ends_with("Cargo.lock") && entry.status() == git2::Status::IGNORED {
// It is OK to include Cargo.lock even if it is ignored.
return None;
}
// Use an absolute path, so that comparing paths is easier
// (particularly with submodules).
Some(workdir.join(path))
});
dirty_files.extend(this_dirty);
Ok(())
}
/// Helper to collect dirty statuses while recursing into submodules.
fn status_submodules(repo: &git2::Repository, dirty_files: &mut Vec<PathBuf>) -> CargoResult<()> {
for submodule in repo.submodules()? {
// Ignore submodules that don't open, they are probably not initialized.
// If its files are required, then the verification step should fail.
if let Ok(sub_repo) = submodule.open() {
status_submodules(&sub_repo, dirty_files)?;
collect_statuses(&sub_repo, &[], dirty_files)?;
}
}
Ok(())
}
/// Use pathspec so git only matches a certain path prefix
fn relative_pathspec(repo: &git2::Repository, pkg_root: &Path) -> String {
let workdir = repo.workdir().unwrap();
let relpath = pkg_root.strip_prefix(workdir).unwrap_or(Path::new(""));
// to unix separators
relpath.to_str().unwrap().replace('\\', "/")
}

View File

@ -3162,9 +3162,10 @@ fn dirty_submodule() {
.with_stderr_data(str![[r#"
[WARNING] manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
[ERROR] 1 files in the working directory contain changes that were not yet committed into git:
[ERROR] 2 files in the working directory contain changes that were not yet committed into git:
.gitmodules
src/lib.rs
to proceed despite this and include the uncommitted changes, pass the `--allow-dirty` flag
@ -3208,9 +3209,10 @@ to proceed despite this and include the uncommitted changes, pass the `--allow-d
.with_stderr_data(str![[r#"
[WARNING] manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
[ERROR] 1 files in the working directory contain changes that were not yet committed into git:
[ERROR] 2 files in the working directory contain changes that were not yet committed into git:
src/.gitmodules
src/bar/mod.rs
to proceed despite this and include the uncommitted changes, pass the `--allow-dirty` flag

View File

@ -1347,7 +1347,7 @@ fn dirty_file_outside_pkg_root_considered_dirty() {
git::commit(&repo);
// Changing files outside pkg root under situations below should be treated
// as dirty. `cargo package` is expected to fail on VCS stastus check.
// as dirty. `cargo package` is expected to fail on VCS status check.
//
// * Changes in files outside package root that source files symlink to
p.change_file("README.md", "after");
@ -1355,7 +1355,7 @@ fn dirty_file_outside_pkg_root_considered_dirty() {
p.change_file("original-dir/file", "after");
// * Changes in files outside pkg root that `license-file`/`readme` point to
p.change_file("LICENSE", "after");
// * When workspace root manifest has changned,
// * When workspace root manifest has changed,
// no matter whether workspace inheritance is involved.
p.change_file(
"Cargo.toml",
@ -1367,7 +1367,7 @@ fn dirty_file_outside_pkg_root_considered_dirty() {
edition = "2021"
"#,
);
// Changes in files outside git workdir won't affect vcs status check
// Changes in files outside git workdir won't affect VCS status check
p.change_file(
&main_outside_pkg_root,
r#"fn main() { eprintln!("after"); }"#,
@ -1470,13 +1470,16 @@ fn dirty_file_outside_pkg_root_inside_submodule() {
p.symlink("submodule/file.txt", "isengard/src/file.txt");
git::add(&repo);
git::commit(&repo);
// This dirtyness should be detected in the future.
p.change_file("submodule/file.txt", "changed");
p.cargo("package --workspace --no-verify")
.with_status(101)
.with_stderr_data(str![[r#"
[PACKAGING] isengard v0.0.0 ([ROOT]/foo/isengard)
[PACKAGED] 6 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
[ERROR] 1 files in the working directory contain changes that were not yet committed into git:
isengard/src/file.txt
to proceed despite this and include the uncommitted changes, pass the `--allow-dirty` flag
"#]])
.run();