Skip to content

Commit a9e1a34

Browse files
authored
fix(package): report lockfile / workspace manifest is dirty (#15276)
### What does this PR try to resolve? Workspace manifest and lockfile might not be under the package root, but workspace inheritance and outdated lockfile entries may still affect what is packaged into the `.crate.` tarball. We take a conservative action that if any of them is dirty, then all workspace members are considered dirty. Fixes #14967 ### How should we test and review this PR? Workspace manifest case is simple. Lockfile requires a bit more works. It is allowed to be missing, and can be generated during packaging. We skip checking when it is missing in both workdir and git index, otherwise cargo will fail with git2 not found error.
2 parents 73b3092 + f0907fc commit a9e1a34

File tree

3 files changed

+195
-29
lines changed

3 files changed

+195
-29
lines changed

src/cargo/ops/cargo_package/mod.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ fn prepare_archive(
384384
let src_files = src.list_files(pkg)?;
385385

386386
// Check (git) repository state, getting the current commit hash.
387-
let vcs_info = vcs::check_repo_state(pkg, &src_files, gctx, &opts)?;
387+
let vcs_info = vcs::check_repo_state(pkg, &src_files, ws, &opts)?;
388388

389389
build_ar_list(ws, pkg, src_files, vcs_info)
390390
}

src/cargo/ops/cargo_package/vcs.rs

+62-17
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ use serde::Serialize;
1010
use tracing::debug;
1111

1212
use crate::core::Package;
13+
use crate::core::Workspace;
14+
use crate::ops::lockfile::LOCKFILE_NAME;
1315
use crate::sources::PathEntry;
1416
use crate::CargoResult;
1517
use crate::GlobalContext;
@@ -44,12 +46,16 @@ pub struct GitVcsInfo {
4446
pub fn check_repo_state(
4547
p: &Package,
4648
src_files: &[PathEntry],
47-
gctx: &GlobalContext,
49+
ws: &Workspace<'_>,
4850
opts: &PackageOpts<'_>,
4951
) -> CargoResult<Option<VcsInfo>> {
52+
let gctx = ws.gctx();
5053
let Ok(repo) = git2::Repository::discover(p.root()) else {
5154
gctx.shell().verbose(|shell| {
52-
shell.warn(format!("no (git) VCS found for `{}`", p.root().display()))
55+
shell.warn(format_args!(
56+
"no (git) VCS found for `{}`",
57+
p.root().display()
58+
))
5359
})?;
5460
// No Git repo found. Have to assume it is clean.
5561
return Ok(None);
@@ -69,7 +75,7 @@ pub fn check_repo_state(
6975
let path = paths::strip_prefix_canonical(path, workdir).unwrap_or_else(|_| path.to_path_buf());
7076
let Ok(status) = repo.status_file(&path) else {
7177
gctx.shell().verbose(|shell| {
72-
shell.warn(format!(
78+
shell.warn(format_args!(
7379
"no (git) Cargo.toml found at `{}` in workdir `{}`",
7480
path.display(),
7581
workdir.display()
@@ -82,7 +88,7 @@ pub fn check_repo_state(
8288

8389
if !(status & git2::Status::IGNORED).is_empty() {
8490
gctx.shell().verbose(|shell| {
85-
shell.warn(format!(
91+
shell.warn(format_args!(
8692
"found (git) Cargo.toml ignored at `{}` in workdir `{}`",
8793
path.display(),
8894
workdir.display()
@@ -100,16 +106,17 @@ pub fn check_repo_state(
100106
path.display(),
101107
workdir.display(),
102108
);
109+
let Some(git) = git(ws, p, src_files, &repo, &opts)? else {
110+
// If the git repo lacks essensial field like `sha1`, and since this field exists from the beginning,
111+
// then don't generate the corresponding file in order to maintain consistency with past behavior.
112+
return Ok(None);
113+
};
114+
103115
let path_in_vcs = path
104116
.parent()
105117
.and_then(|p| p.to_str())
106118
.unwrap_or("")
107119
.replace("\\", "/");
108-
let Some(git) = git(p, gctx, src_files, &repo, &opts)? else {
109-
// If the git repo lacks essensial field like `sha1`, and since this field exists from the beginning,
110-
// then don't generate the corresponding file in order to maintain consistency with past behavior.
111-
return Ok(None);
112-
};
113120

114121
return Ok(Some(VcsInfo { git, path_in_vcs }));
115122
}
@@ -162,8 +169,8 @@ fn warn_symlink_checked_out_as_plain_text_file(
162169

163170
/// The real git status check starts from here.
164171
fn git(
172+
ws: &Workspace<'_>,
165173
pkg: &Package,
166-
gctx: &GlobalContext,
167174
src_files: &[PathEntry],
168175
repo: &git2::Repository,
169176
opts: &PackageOpts<'_>,
@@ -184,12 +191,12 @@ fn git(
184191
// Find the intersection of dirty in git, and the src_files that would
185192
// be packaged. This is a lazy n^2 check, but seems fine with
186193
// thousands of files.
187-
let cwd = gctx.cwd();
194+
let cwd = ws.gctx().cwd();
188195
let mut dirty_src_files: Vec<_> = src_files
189196
.iter()
190197
.filter(|src_file| dirty_files.iter().any(|path| src_file.starts_with(path)))
191198
.map(|p| p.as_ref())
192-
.chain(dirty_files_outside_pkg_root(pkg, repo, src_files)?.iter())
199+
.chain(dirty_files_outside_pkg_root(ws, pkg, repo, src_files)?.iter())
193200
.map(|path| {
194201
pathdiff::diff_paths(path, cwd)
195202
.as_ref()
@@ -228,41 +235,79 @@ fn git(
228235
///
229236
/// * `package.readme` and `package.license-file` pointing to paths outside package root
230237
/// * symlinks targets reside outside package root
238+
/// * Any change in the root workspace manifest, regardless of what has changed.
239+
/// * Changes in the lockfile [^1].
231240
///
232241
/// This is required because those paths may link to a file outside the
233242
/// current package root, but still under the git workdir, affecting the
234243
/// final packaged `.crate` file.
244+
///
245+
/// [^1]: Lockfile might be re-generated if it is too out of sync with the manifest.
246+
/// Therefore, even you have a modified lockfile,
247+
/// you might still get a new fresh one that matches what is in git index.
235248
fn dirty_files_outside_pkg_root(
249+
ws: &Workspace<'_>,
236250
pkg: &Package,
237251
repo: &git2::Repository,
238252
src_files: &[PathEntry],
239253
) -> CargoResult<HashSet<PathBuf>> {
240254
let pkg_root = pkg.root();
241255
let workdir = repo.workdir().unwrap();
242256

257+
let mut dirty_files = HashSet::new();
258+
243259
let meta = pkg.manifest().metadata();
244260
let metadata_paths: Vec<_> = [&meta.license_file, &meta.readme]
245261
.into_iter()
246262
.filter_map(|p| p.as_deref())
247263
.map(|path| paths::normalize_path(&pkg_root.join(path)))
248264
.collect();
249265

250-
let mut dirty_symlinks = HashSet::new();
266+
// Unlike other files, lockfile is allowed to be missing,
267+
// and can be generated during packaging.
268+
// We skip checking when it is missing in both workdir and git index,
269+
// otherwise cargo will fail with git2 not found error.
270+
let lockfile_path = ws.lock_root().as_path_unlocked().join(LOCKFILE_NAME);
271+
let lockfile_path = if lockfile_path.exists() {
272+
Some(lockfile_path)
273+
} else if let Ok(rel_path) = paths::normalize_path(&lockfile_path).strip_prefix(workdir) {
274+
// We don't canonicalize here because non-existing path can't be canonicalized.
275+
match repo.status_file(&rel_path) {
276+
Ok(s) if s != git2::Status::CURRENT => {
277+
dirty_files.insert(lockfile_path);
278+
}
279+
// Unmodified
280+
Ok(_) => {}
281+
Err(e) => {
282+
debug!(
283+
"check git status failed for `{}` in workdir `{}`: {e}",
284+
rel_path.display(),
285+
workdir.display(),
286+
);
287+
}
288+
}
289+
None
290+
} else {
291+
None
292+
};
293+
251294
for rel_path in src_files
252295
.iter()
253296
.filter(|p| p.is_symlink_or_under_symlink())
254-
.map(|p| p.as_ref())
255-
.chain(metadata_paths.iter())
297+
.map(|p| p.as_ref().as_path())
298+
.chain(metadata_paths.iter().map(AsRef::as_ref))
299+
.chain([ws.root_manifest()])
300+
.chain(lockfile_path.as_deref().into_iter())
256301
// If inside package root. Don't bother checking git status.
257302
.filter(|p| paths::strip_prefix_canonical(p, pkg_root).is_err())
258303
// Handle files outside package root but under git workdir,
259304
.filter_map(|p| paths::strip_prefix_canonical(p, workdir).ok())
260305
{
261306
if repo.status_file(&rel_path)? != git2::Status::CURRENT {
262-
dirty_symlinks.insert(workdir.join(rel_path));
307+
dirty_files.insert(workdir.join(rel_path));
263308
}
264309
}
265-
Ok(dirty_symlinks)
310+
Ok(dirty_files)
266311
}
267312

268313
/// Helper to collect dirty statuses for a single repo.

tests/testsuite/package.rs

+132-11
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use std::fs::{self, read_to_string, File};
44
use std::path::Path;
55

6+
use cargo_test_support::compare::assert_e2e;
67
use cargo_test_support::prelude::*;
78
use cargo_test_support::publish::validate_crate_contents;
89
use cargo_test_support::registry::{self, Package};
@@ -1212,15 +1213,6 @@ fn vcs_status_check_for_each_workspace_member() {
12121213
});
12131214
git::commit(&repo);
12141215

1215-
p.change_file(
1216-
"Cargo.toml",
1217-
r#"
1218-
[workspace]
1219-
members = ["isengard", "mordor"]
1220-
[workspace.package]
1221-
edition = "2021"
1222-
"#,
1223-
);
12241216
// Dirty file outside won't affect packaging.
12251217
p.change_file("hobbit", "changed!");
12261218
p.change_file("mordor/src/lib.rs", "changed!");
@@ -1357,7 +1349,8 @@ fn dirty_file_outside_pkg_root_considered_dirty() {
13571349
p.change_file("original-dir/file", "after");
13581350
// * Changes in files outside pkg root that `license-file`/`readme` point to
13591351
p.change_file("LICENSE", "after");
1360-
// * When workspace inheritance is involved and changed
1352+
// * When workspace root manifest has changned,
1353+
// no matter whether workspace inheritance is involved.
13611354
p.change_file(
13621355
"Cargo.toml",
13631356
r#"
@@ -1378,8 +1371,9 @@ fn dirty_file_outside_pkg_root_considered_dirty() {
13781371
p.cargo("package --workspace --no-verify")
13791372
.with_status(101)
13801373
.with_stderr_data(str![[r#"
1381-
[ERROR] 4 files in the working directory contain changes that were not yet committed into git:
1374+
[ERROR] 5 files in the working directory contain changes that were not yet committed into git:
13821375
1376+
Cargo.toml
13831377
LICENSE
13841378
README.md
13851379
lib.rs
@@ -1432,6 +1426,133 @@ edition = "2021"
14321426
);
14331427
}
14341428

1429+
#[cargo_test]
1430+
fn dirty_ws_lockfile_dirty() {
1431+
let (p, repo) = git::new_repo("foo", |p| {
1432+
p.file(
1433+
"Cargo.toml",
1434+
r#"
1435+
[workspace]
1436+
members = ["isengard"]
1437+
resolver = "2"
1438+
[workspace.package]
1439+
edition = "2015"
1440+
"#,
1441+
)
1442+
.file(
1443+
"isengard/Cargo.toml",
1444+
r#"
1445+
[package]
1446+
name = "isengard"
1447+
edition.workspace = true
1448+
homepage = "saruman"
1449+
description = "saruman"
1450+
license = "MIT"
1451+
"#,
1452+
)
1453+
.file("isengard/src/lib.rs", "")
1454+
});
1455+
git::commit(&repo);
1456+
1457+
p.cargo("package --workspace --no-verify")
1458+
.with_stderr_data(str![[r#"
1459+
[PACKAGING] isengard v0.0.0 ([ROOT]/foo/isengard)
1460+
[PACKAGED] 5 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
1461+
1462+
"#]])
1463+
.run();
1464+
1465+
// lockfile is untracked.
1466+
p.cargo("generate-lockfile").run();
1467+
p.cargo("package --workspace --no-verify")
1468+
.with_status(101)
1469+
.with_stderr_data(str![[r#"
1470+
[ERROR] 1 files in the working directory contain changes that were not yet committed into git:
1471+
1472+
Cargo.lock
1473+
1474+
to proceed despite this and include the uncommitted changes, pass the `--allow-dirty` flag
1475+
1476+
"#]])
1477+
.run();
1478+
1479+
// lockfile is tracked.
1480+
p.cargo("clean").run();
1481+
git::add(&repo);
1482+
git::commit(&repo);
1483+
p.cargo("package --workspace --no-verify")
1484+
.with_stderr_data(str![[r#"
1485+
[PACKAGING] isengard v0.0.0 ([ROOT]/foo/isengard)
1486+
[PACKAGED] 5 files, [FILE_SIZE]B ([FILE_SIZE]B compressed)
1487+
1488+
"#]])
1489+
.run();
1490+
1491+
// Simulate that Cargo.lock had some outdated but SemVer compat dependency changes.
1492+
Package::new("dep", "1.0.0").publish();
1493+
Package::new("dep", "1.1.0").publish();
1494+
p.cargo("clean").run();
1495+
p.cargo("add dep@1").run();
1496+
git::add(&repo);
1497+
git::commit(&repo);
1498+
// make sure we have [email protected] in lockfile
1499+
assert_e2e().eq(
1500+
&p.read_lockfile(),
1501+
str![[r##"
1502+
# This file is automatically @generated by Cargo.
1503+
# It is not intended for manual editing.
1504+
version = 4
1505+
1506+
[[package]]
1507+
name = "dep"
1508+
version = "1.1.0"
1509+
source = "registry+https://github.com/rust-lang/crates.io-index"
1510+
checksum = "77d3d6a4f2203d590707cc803c94afbe36393bbdba757ef66986f39159eaab51"
1511+
1512+
[[package]]
1513+
name = "isengard"
1514+
version = "0.0.0"
1515+
dependencies = [
1516+
"dep",
1517+
]
1518+
1519+
"##]],
1520+
);
1521+
p.cargo("update dep --precise 1.0.0")
1522+
.with_stderr_data(str![[r#"
1523+
[UPDATING] `dummy-registry` index
1524+
[DOWNGRADING] dep v1.1.0 -> v1.0.0
1525+
1526+
"#]])
1527+
.run();
1528+
p.cargo("package --workspace --no-verify")
1529+
.with_status(101)
1530+
.with_stderr_data(str![[r#"
1531+
[ERROR] 1 files in the working directory contain changes that were not yet committed into git:
1532+
1533+
Cargo.lock
1534+
1535+
to proceed despite this and include the uncommitted changes, pass the `--allow-dirty` flag
1536+
1537+
"#]])
1538+
.run();
1539+
1540+
// Now check if it is considered dirty when removed.
1541+
p.cargo("clean").run();
1542+
fs::remove_file(p.root().join("Cargo.lock")).unwrap();
1543+
p.cargo("package --workspace --no-verify")
1544+
.with_status(101)
1545+
.with_stderr_data(str![[r#"
1546+
[ERROR] 1 files in the working directory contain changes that were not yet committed into git:
1547+
1548+
Cargo.lock
1549+
1550+
to proceed despite this and include the uncommitted changes, pass the `--allow-dirty` flag
1551+
1552+
"#]])
1553+
.run();
1554+
}
1555+
14351556
#[cargo_test]
14361557
fn issue_13695_allow_dirty_vcs_info() {
14371558
let p = project()

0 commit comments

Comments
 (0)