diff --git a/README.zh-CN.md b/README.zh-CN.md
index 5c0fdb1f4..e44c3ae79 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -8,11 +8,11 @@
[](https://en.cppreference.com/w/cpp/23)
[](https://en.cppreference.com/w/cpp/language/modules)
[](LICENSE)
+[](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml)
| [文档](docs/zh/) · [快速开始](docs/zh/01-getting-started.md) · [mcpp.toml 指南](docs/zh/04-mcpp-toml.md) · [示例项目](docs/zh/03-examples.md) · [工具链管理](docs/zh/20-toolchains.md) |
|:---:|
| [包索引 mcpp-index](https://mcpplibs.github.io/mcpp-index/) · [模块化库 mcpplibs](https://github.com/mcpplibs) · [社区论坛](https://forum.d2learn.org/category/20) · [Issues](https://github.com/mcpp-community/mcpp/issues) · [Releases](https://github.com/mcpp-community/mcpp/releases) |
-| [](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml) |
| 支持的插件 · [mcpp-language-server(mcppls)](https://github.com/Sunrisepeak/mcpp-language-server) —— C++20/23 模块语言服务器,面向 VS Code、Zed、CLion、Neovim、AI Agent(MCP)与 CI |
diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md
index d571e5c98..10a925383 100644
--- a/docs/30-build-mcpp.md
+++ b/docs/30-build-mcpp.md
@@ -560,7 +560,10 @@ int main() {
```
The pattern is relative to the manifest directory and uses the same `*` / `**`
-grammar as `sources = [...]`. Its fingerprint is the **sorted set of matching
+grammar as `sources = [...]`. A literal directory prefix can leave the package:
+`../inputs/**/*.in` watches a sibling directory, including its creation after
+the first build. Output and `.git` directories remain excluded, and directory
+symlinks are not followed. Its fingerprint is the **sorted set of matching
paths** and nothing else:
- **not contents** — a file whose bytes matter is an ordinary
diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md
index 8751c6f58..5f1897b1e 100644
--- a/docs/zh/30-build-mcpp.md
+++ b/docs/zh/30-build-mcpp.md
@@ -481,7 +481,9 @@ int main() {
}
```
-模式相对 manifest 目录,`*` / `**` 的文法与 `sources = [...]` 完全一致。它的指纹
+模式相对 manifest 目录,`*` / `**` 的文法与 `sources = [...]` 完全一致。固定目录
+前缀可以指向包外:`../inputs/**/*.in` 监视同级目录,包括首次构建后才创建该目录
+的情况。构建输出目录与 `.git` 仍被排除,也不跟随目录符号链接。它的指纹
是**排序后的匹配路径集合**,不含其他任何东西:
- **不含内容** —— 字节内容重要的文件本来就该用 `rerun_if_changed` 声明,那条
diff --git a/modules/buildmcpp/src/directives.cppm b/modules/buildmcpp/src/directives.cppm
index 279dc3f1e..0743440a2 100644
--- a/modules/buildmcpp/src/directives.cppm
+++ b/modules/buildmcpp/src/directives.cppm
@@ -936,19 +936,36 @@ std::string glob_fingerprint(const std::filesystem::path& root,
namespace fs = std::filesystem;
std::vector hits;
std::error_code ec;
+ const auto empty = mcpp::toolchain::hash_string("");
+ const auto prefix = mcpp::modgraph::glob_literal_prefix(pattern);
+ // Starting at the literal prefix must not bypass the exclusions applied
+ // during traversal, or target/** and symlink/subdir/** would enter them.
+ auto start = root;
+ for (const auto& component : prefix) {
+ if (component == ".git" || (!outputDirName.empty()
+ && component == mcpp::modgraph::native_path_from_generic(outputDirName)))
+ return empty;
+ start /= component;
+ if (fs::is_symlink(start, ec)) return empty;
+ }
+ start = start.lexically_normal();
+ ec.clear();
// skip_permission_denied only: symlinked directories are NOT followed, the
// same rule the source scan uses, so a self-referential link cannot make
// this walk diverge.
fs::recursive_directory_iterator it(
- root, fs::directory_options::skip_permission_denied, ec);
- if (ec) return {};
+ start, fs::directory_options::skip_permission_denied, ec);
+ // A missing input directory is the same empty set as an existing directory
+ // containing no matches. Its first matching file will invalidate the key.
+ if (ec) return empty;
for (; it != fs::recursive_directory_iterator(); it.increment(ec)) {
if (ec) break;
const auto& p = it->path();
std::error_code dec;
if (it->is_directory(dec)) {
- auto name = p.filename().string();
- if (name == ".git" || (!outputDirName.empty() && name == outputDirName)) {
+ const auto name = p.filename();
+ if (name == ".git" || (!outputDirName.empty()
+ && name == mcpp::modgraph::native_path_from_generic(outputDirName))) {
it.disable_recursion_pending();
continue;
}
@@ -956,13 +973,12 @@ std::string glob_fingerprint(const std::filesystem::path& root,
continue;
}
if (!mcpp::modgraph::path_matches_glob(p, root, pattern)) continue;
- std::string rel;
- try {
- rel = p.lexically_relative(root).generic_string();
- } catch (const std::exception&) {
- continue; // unspellable name — see path_matches_glob
+ auto rel = mcpp::modgraph::try_narrow(p.lexically_relative(root));
+ if (!rel) {
+ mcpp::modgraph::note_unnarrowable_path(p);
+ continue;
}
- hits.push_back(std::move(rel));
+ hits.push_back(std::move(*rel));
}
std::ranges::sort(hits);
std::string joined;
diff --git a/modules/buildmcpp/tests/test_glob_inputs.cpp b/modules/buildmcpp/tests/test_glob_inputs.cpp
new file mode 100644
index 000000000..cec501204
--- /dev/null
+++ b/modules/buildmcpp/tests/test_glob_inputs.cpp
@@ -0,0 +1,108 @@
+#include
+
+import std;
+import mcpp.build.directives;
+import mcpp.toolchain.fingerprint;
+
+namespace dirs = mcpp::build::directives;
+
+namespace {
+
+struct GlobInputs : testing::Test {
+ std::filesystem::path tree;
+ std::filesystem::path root;
+
+ void SetUp() override {
+ tree = std::filesystem::temp_directory_path() / "mcpp_parent_glob_inputs";
+ std::filesystem::remove_all(tree);
+ root = tree / "workspace" / "app";
+ std::filesystem::create_directories(root);
+ }
+
+ void TearDown() override {
+ std::error_code ec;
+ std::filesystem::remove_all(tree, ec);
+ }
+
+ void write(const std::filesystem::path& relative, std::string_view content = "x") {
+ const auto path = root / relative;
+ std::filesystem::create_directories(path.parent_path());
+ std::ofstream out(path);
+ out << content;
+ }
+
+ std::string fingerprint(std::string_view pattern) {
+ return dirs::glob_fingerprint(root, pattern, "target");
+ }
+};
+
+} // namespace
+
+TEST_F(GlobInputs, ParentDirectoryMembershipInvalidatesTheFingerprint) {
+ write("../inputs/a.in");
+ const auto one = fingerprint("../inputs/**/*.in");
+ EXPECT_NE(one, mcpp::toolchain::hash_string(""));
+
+ write("../inputs/nested/b.in");
+ EXPECT_NE(fingerprint("../inputs/**/*.in"), one);
+ std::filesystem::remove(root / "../inputs/nested/b.in");
+ EXPECT_EQ(fingerprint("../inputs/**/*.in"), one);
+
+ write("../inputs/a.in", "different contents");
+ write("../inputs/ignored.txt");
+ write("../unrelated/c.in");
+ EXPECT_EQ(fingerprint("../inputs/**/*.in"), one);
+}
+
+TEST_F(GlobInputs, MultipleParentLevelsAndExactPathsAreWatched) {
+ const auto before = fingerprint("../../inputs/*.in");
+ write("../../inputs/a.in");
+ EXPECT_NE(fingerprint("../../inputs/*.in"), before);
+ EXPECT_NE(fingerprint("../../inputs/a.in"), before);
+ std::filesystem::remove(root / "../../inputs/a.in");
+ EXPECT_EQ(fingerprint("../../inputs/*.in"), before);
+}
+
+TEST_F(GlobInputs, MissingLiteralDirectoryDoesNotHideLaterFiles) {
+ const auto before = fingerprint("../missing/**/*.in");
+ write("../missing/ignored.txt");
+ EXPECT_EQ(fingerprint("../missing/**/*.in"), before);
+ write("../missing/nested/a.in");
+ EXPECT_NE(fingerprint("../missing/**/*.in"), before);
+}
+
+TEST_F(GlobInputs, LiteralPrefixDoesNotBypassExcludedDirectories) {
+ const auto before = fingerprint("**");
+ write("target/generated.in");
+ write(".git/index.in");
+ write("../inputs/target/generated.in");
+ write("../inputs/.git/index.in");
+ EXPECT_EQ(fingerprint("**"), before);
+ for (const auto pattern : {"target/**", ".git/**", "../inputs/target/**",
+ "../inputs/.git/**", "../inputs/**"})
+ EXPECT_EQ(fingerprint(pattern), mcpp::toolchain::hash_string("")) << pattern;
+}
+
+TEST_F(GlobInputs, ParentPatternsRetainPathSetSemantics) {
+ write("../inputs/a.in");
+ const auto before = fingerprint("../inputs/**");
+ write("../inputs/a.in", "longer content");
+ std::filesystem::last_write_time(root / "../inputs/a.in",
+ std::filesystem::file_time_type::clock::now() + std::chrono::hours(1));
+ EXPECT_EQ(fingerprint("../inputs/**"), before);
+ write("../inputs/nested/b.in");
+ EXPECT_NE(fingerprint("../inputs/**"), before);
+}
+
+TEST_F(GlobInputs, DirectorySymlinksAreNotFollowedThroughTheLiteralPrefix) {
+ write("../real/nested/a.in");
+ std::error_code ec;
+ std::filesystem::create_directory_symlink(tree / "workspace" / "real",
+ root / "../link", ec);
+ if (ec) GTEST_SKIP() << "Directory symlinks are unavailable: " << ec.message();
+ EXPECT_EQ(fingerprint("../link/**/*.in"), mcpp::toolchain::hash_string(""));
+ EXPECT_EQ(fingerprint("../link/nested/*.in"), mcpp::toolchain::hash_string(""));
+ const auto before = fingerprint("../**/*.in");
+ write("../real/nested/b.in");
+ EXPECT_NE(fingerprint("../**/*.in"), before);
+}
diff --git a/modules/manifest/src/glob.cppm b/modules/manifest/src/glob.cppm
index 031a9dc7c..a99f9e0b5 100644
--- a/modules/manifest/src/glob.cppm
+++ b/modules/manifest/src/glob.cppm
@@ -34,6 +34,18 @@ std::filesystem::path native_path_from_generic(std::string_view s) {
return p;
}
+// The complete literal directory prefix before the first wildcard. Both the
+// source scanner and rerun-glob fingerprints use it to choose their walk root,
+// including sibling directories such as ../inputs/**/*.in.
+std::filesystem::path glob_literal_prefix(std::string_view glob) {
+ if (!glob.empty() && glob.front() == '!') glob.remove_prefix(1);
+ const auto wildcard = glob.find_first_of("*?{[");
+ const auto literal = glob.substr(0, wildcard);
+ const auto slash = literal.find_last_of('/');
+ if (slash == std::string_view::npos) return {};
+ return native_path_from_generic(literal.substr(0, slash));
+}
+
// Whether `s` is well-formed UTF-8: no stray continuation byte, no truncated or
// overlong sequence, no surrogate, nothing above U+10FFFF.
bool is_valid_utf8(std::string_view s);
diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm
index b0808ed08..85fa28e38 100644
--- a/src/build/test_targets.cppm
+++ b/src/build/test_targets.cppm
@@ -4,6 +4,7 @@ export module mcpp.build.test_targets;
import std;
import mcpp.manifest;
+import mcpp.modgraph.glob;
import mcpp.modgraph.scanner;
import mcpp.project;
diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm
index 275f68633..731fd1425 100644
--- a/src/modgraph/scanner.cppm
+++ b/src/modgraph/scanner.cppm
@@ -40,13 +40,6 @@ std::vector expand_glob(const std::filesystem::path& root
std::vector expand_dir_glob(const std::filesystem::path& root,
std::string_view glob);
-// mcpp#225 (test-exposed): pure literal (non-wildcard) directory-prefix
-// derivation used to bound expand_glob/expand_dir_glob's walk start point.
-// Exported (rather than kept file-local) solely so unit tests can assert its
-// behavior directly and deterministically — see its definition below for
-// the full contract.
-std::filesystem::path glob_literal_prefix(std::string_view glob);
-
// mcpp#228: desugar brace alternation `{a,b}` into a cartesian product of
// plain globs, e.g. "a/{x,y}/**" -> ["a/x/**", "a/y/**"]. A glob with no `{`
// returns itself unchanged (the common case). Multiple and nested groups are
@@ -458,38 +451,6 @@ bool is_excluded_walk_dir(const std::filesystem::path& dir,
} // namespace
-// mcpp#225: the literal (non-wildcard) directory prefix of a glob, e.g.
-// "src/**/*.cppm" -> "src", "tests/**/*.cpp" -> "tests", "*/include" -> ""
-// (wildcard already in the first segment). Used to bound the walk's START
-// point in expand_glob/expand_dir_glob instead of always walking from root
-// and filtering lexically afterward — path_matches_glob still does the full
-// lexical match, so this only narrows WHERE the iterator begins, never
-// which files can match. Truncates back to the last complete '/' so a
-// partial segment (e.g. "src/pre*fix" -> "src/pre") is never mistaken for a
-// real directory name. Conservative about '{' (and '?'/'['): brace-expansion
-// globs are desugared into multiple plain globs before ever reaching here
-// (mcpp#225 cluster E follow-up), so any of those chars seen here is just a
-// segment boundary, never something to interpret. Declared in the exported
-// namespace above (not kept file-local) purely so unit tests can assert its
-// behavior directly, deterministically, and independently of filesystem
-// enumeration order — see Scanner.GlobLiteralPrefixDerivation.
-std::filesystem::path glob_literal_prefix(std::string_view glob) {
- // Every current caller already strips a leading `!` (exclusion globs)
- // before calling expand_glob/expand_dir_glob, but strip it here too —
- // defense in depth, and a literal '!' is never a real path component.
- if (!glob.empty() && glob.front() == '!') glob.remove_prefix(1);
- auto wildcard = glob.find_first_of("*?{[");
- std::string_view literal = wildcard == std::string_view::npos
- ? glob : glob.substr(0, wildcard);
- auto slash = literal.find_last_of('/');
- if (slash == std::string_view::npos) return {};
- // Native separators, not the raw generic form: MSVC keeps the input's
- // `/` verbatim, and `root / p` plus the directory walk then propagate a
- // MIXED `root\generated/modules` into every downstream path — which is
- // what `compile_commands.json`'s `file` field showed on Windows for
- // multi-segment globs. See mcpp::modgraph::native_path_from_generic.
- return native_path_from_generic(literal.substr(0, slash));
-}
// mcpp#228: `{a,b}` alternation, recursively. Finds the first top-level `{`,
// its MATCHING `}` (brace-depth tracked, so a nested group's inner braces
diff --git a/tests/e2e/00_fixture_path_hygiene.sh b/tests/e2e/00_fixture_path_hygiene.sh
index 1795f2f5d..d1351b084 100755
--- a/tests/e2e/00_fixture_path_hygiene.sh
+++ b/tests/e2e/00_fixture_path_hygiene.sh
@@ -25,6 +25,20 @@
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
+source "$HERE/_host_path.sh"
+
+# CI passes MCPP_HOME in native Windows spelling, not just Git Bash spelling.
+# Both must produce paths safe to interpolate into TOML basic strings.
+case "$(uname -s)" in
+ MINGW* | MSYS* | CYGWIN*)
+ [[ "$(host_path 'C:\Users\runner admin\.mcpp/registry')" == 'C:/Users/runner admin/.mcpp/registry' ]] || {
+ echo "FAIL: host_path did not normalize a native Windows absolute path"
+ exit 1
+ }
+ [[ "$(host_path 'C:/Users/runner admin/.mcpp/registry')" == 'C:/Users/runner admin/.mcpp/registry' ]] || exit 1
+ ;;
+esac
+[[ "$(host_path '../relative directory')" == '../relative directory' ]] || exit 1
# A TOML `path` key: either at the start of a line, or inside an inline table
# (`{ path = "..." }`). Anchoring this way is what keeps shell assignments such
diff --git a/tests/e2e/882_parent_directory_glob_inputs.sh b/tests/e2e/882_parent_directory_glob_inputs.sh
new file mode 100755
index 000000000..057820ecb
--- /dev/null
+++ b/tests/e2e/882_parent_directory_glob_inputs.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+# requires:
+# A sibling input directory must invalidate both build.mcpp's cache and the
+# project fast path. The generated executable is the observable result.
+set -euo pipefail
+source "$(dirname "$0")/_host_path.sh"
+
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+MCPP="${MCPP:-mcpp}"
+REGISTRY_HOST=$(host_path "${MCPP_HOME:-$HOME/.mcpp}/registry")
+export MCPP_HOME="$TMP/mcpp-home"
+mkdir -p "$MCPP_HOME" "$TMP/ws/app/src" "$TMP/logs"
+cat > "$MCPP_HOME/config.toml" < mcpp.toml <<'EOF'
+[package]
+name = "parent-glob"
+version = "0.1.0"
+
+[targets.parent-glob]
+kind = "bin"
+main = "src/main.cpp"
+EOF
+cat > src/main.cpp <<'EOF'
+#include
+int count_inputs();
+int main() { std::printf("COUNT=%d\n", count_inputs()); }
+EOF
+cat > build.mcpp <<'EOF'
+import std;
+import mcpp;
+int main() {
+ namespace fs = std::filesystem;
+ const fs::path inputs = fs::path(mcpp::manifest_dir()) / "../inputs";
+ mcpp::rerun_if_changed_glob("../inputs/**/*.in");
+ int count = 0;
+ std::error_code ec;
+ for (const auto& entry : fs::recursive_directory_iterator(inputs, ec))
+ if (entry.is_regular_file() && entry.path().extension() == ".in") ++count;
+ const std::string output = std::string(mcpp::out_dir()) + "/count.cpp";
+ { std::ofstream out(output); out << "int count_inputs() { return " << count << "; }\n"; }
+ mcpp::generated(output.c_str());
+ return 0;
+}
+EOF
+
+fail() { echo "FAIL: $1"; cat "$TMP/logs"/*.log; exit 1; }
+build_count() {
+ local step=$1 expected=$2
+ "$MCPP" build > "$TMP/logs/$step.log" 2>&1 || fail "build $step failed"
+ local output
+ output=$("$MCPP" run 2>&1 | grep '^COUNT=' | tail -1)
+ [[ "$output" == "COUNT=$expected" ]] || fail "$step expected COUNT=$expected, got $output"
+}
+
+# The literal prefix initially does not exist. Creating the sibling directory
+# and its first input must rerun without touching a source or manifest.
+build_count missing 0
+mkdir -p ../inputs
+printf 'a\n' > ../inputs/a.in
+build_count appeared 1
+mkdir -p ../inputs/nested
+printf 'b\n' > ../inputs/nested/b.in
+build_count added 2
+rm ../inputs/nested/b.in
+build_count removed 1
+
+# Glob inputs track membership only. A content edit, a nonmatching file, and
+# an unchanged build must preserve the fast path instead of always rerunning.
+printf 'different content\n' > ../inputs/a.in
+printf 'not an input\n' > ../inputs/ignored.txt
+build_count content 1
+build_count unchanged 1
+for step in content unchanged; do
+ if grep -qE '^ *build\.mcpp .* ran [0-9]' "$TMP/logs/$step.log"; then
+ fail "$step reran the build program despite unchanged membership"
+ fi
+done
+
+echo "PASS: parent-directory glob inputs invalidate builds on membership changes"
diff --git a/tests/e2e/_host_path.sh b/tests/e2e/_host_path.sh
index 165cb8657..aff28632e 100644
--- a/tests/e2e/_host_path.sh
+++ b/tests/e2e/_host_path.sh
@@ -36,8 +36,9 @@ host_path() {
# agree and Windows accepts `/` as a separator. Passing one to `cygpath -m`
# would silently ANCHOR it at the current directory — which is not where the
# manifest being written lives — so relative input is returned untouched.
+ # MCPP_HOME can arrive from CI as C:\Users\...; it is absolute too.
case "$1" in
- /*) ;;
+ /* | [A-Za-z]:[\\/]*) ;;
*) printf '%s' "$1"; return 0 ;;
esac
case "$(uname -s)" in