From c3e709543dfedd272c7d4a78acabdf7d80280125 Mon Sep 17 00:00:00 2001 From: Zhe Feng Date: Sat, 1 Aug 2026 17:36:55 +0800 Subject: [PATCH 1/4] feat: use mcpp.lock commit as offline anchor for git deps - Add LockedGitSource + parse_git_source to lock_io.cppm. - Load mcpp.lock in prepare_build and use recorded branch commit to skip git ls-remote when the local cache still matches. - For tag/rev, reuse cached clone and fail early in --offline when missing. - Update e2e test 24 to assert branch deps do not re-ls-remote on rebuild. - Add unit tests for git source parsing. Closes #329 --- src/build/prepare.cppm | 123 +++++++++++++++++++++++++++++---- src/pm/lock_io.cppm | 49 +++++++++++++ tests/e2e/24_git_dependency.sh | 5 ++ tests/unit/test_pm_lock_io.cpp | 56 +++++++++++++++ 4 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 tests/unit/test_pm_lock_io.cpp diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 2749d08c..674f1f4e 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -46,6 +46,7 @@ import mcpp.pm.index_refresh; import mcpp.pm.mangle; import mcpp.pm.compat; import mcpp.pm.dep_spec; +import mcpp.pm.lock_io; import mcpp.version_req; import mcpp.ui; import mcpp.log; @@ -838,6 +839,26 @@ prepare_build(bool print_fingerprint, mcpp::diag::warning("manifest/schema", w); } + // Load mcpp.lock once. Git-based deps can use it as an offline anchor: + // if a branch is already resolved to a commit and the local cache matches, + // no network round-trip is needed. + std::map gitLockAnchors; + { + auto lockPath = *root / "mcpp.lock"; + if (std::filesystem::exists(lockPath)) { + if (auto lock = mcpp::lockfile::load(lockPath); lock) { + for (auto const& p : lock->packages) { + if (auto parsed = mcpp::pm::parse_git_source(p.source); parsed) { + gitLockAnchors.emplace(p.name, std::move(*parsed)); + } + } + } else { + mcpp::diag::warning("lockfile", + std::format("ignoring mcpp.lock: {}", lock.error().message)); + } + } + } + // Global-cache mode: --cache > MCPP_BUILD_CACHE > [build] cache > global. // An unparseable value is a warning (error under --strict) and falls // through to the next source rather than silently meaning "global" — a typo @@ -2987,24 +3008,102 @@ prepare_build(bool print_fingerprint, // them to a commit before forming the cache key; this lets // `mcpp update ` pick up a moved branch without deleting // unrelated git caches. + // + // mcpp.lock acts as an offline anchor: if a branch is already + // resolved to a commit and the local cache still matches it, + // we skip the network round-trip entirely. auto mcppHome = mcpp::home::root(); // single resolver (#311) std::string resolvedGitRev = spec.gitRev; + bool skipLsRemote = false; + + // Look up an offline anchor for this git dep. + std::optional lockedCommit; + if (auto it = gitLockAnchors.find(name); it != gitLockAnchors.end()) { + auto const& anchor = it->second; + if (anchor.refKind == spec.gitRefKind && anchor.ref == spec.gitRev) { + lockedCommit = anchor.resolvedCommit; + } + } + + auto computeGitRoot = [&](const std::string& rev) { + std::hash H; + auto urlHash = std::format("{:016x}", + H(spec.git + "|" + spec.gitRefKind + "|" + spec.gitRev + + "|" + rev)); + return mcppHome / "git" / urlHash; + }; + + auto readCacheHead = [&](const std::filesystem::path& gitRoot) + -> std::string + { + auto cmd = std::format("git -C {} rev-parse HEAD 2>&1", + mcpp::platform::shell::quote(gitRoot.string())); + auto r = mcpp::platform::process::capture(cmd); + if (r.exit_code != 0) return {}; + std::string head = r.output; + head.erase(head.find_last_not_of(" \r\n\t") + 1); + return head; + }; + if (spec.gitRefKind == "branch") { auto ref = std::format("refs/heads/{}", spec.gitRev); - auto cmd = std::format( - "git ls-remote {} {} 2>&1", - mcpp::platform::shell::quote(spec.git), - mcpp::platform::shell::quote(ref)); - auto r = mcpp::platform::process::capture(cmd); - if (r.exit_code != 0) { - return std::unexpected(std::format( - "git ls-remote of '{}' failed:\n{}", spec.git, r.output)); + if (lockedCommit && !lockedCommit->empty()) { + resolvedGitRev = *lockedCommit; + auto gitRoot = computeGitRoot(resolvedGitRev); + if (std::filesystem::exists(gitRoot / ".git") && + readCacheHead(gitRoot) == resolvedGitRev) { + skipLsRemote = true; + mcpp::ui::info("Resolved", + std::format("{} ({} = {}) from lock", + spec.git, spec.gitRefKind, spec.gitRev)); + } + } + if (!skipLsRemote) { + if (mcpp::platform::env::offline_mode()) { + if (lockedCommit) { + return std::unexpected(std::format( + "git dep '{}' locked to commit {} but its local cache is missing or stale; " + "run without --offline to refresh, or `mcpp update {}` to re-resolve", + name, *lockedCommit, name)); + } else { + return std::unexpected(std::format( + "git dep '{}' uses branch '{}' and mcpp.lock has no commit; " + "cannot resolve offline. Run `mcpp update {}` or build without --offline.", + name, spec.gitRev, name)); + } + } + auto cmd = std::format( + "git ls-remote {} {} 2>&1", + mcpp::platform::shell::quote(spec.git), + mcpp::platform::shell::quote(ref)); + auto r = mcpp::platform::process::capture(cmd); + if (r.exit_code != 0) { + return std::unexpected(std::format( + "git ls-remote of '{}' failed:\n{}", spec.git, r.output)); + } + std::istringstream is(r.output); + is >> resolvedGitRev; + if (resolvedGitRev.empty()) { + return std::unexpected(std::format( + "git branch '{}' not found in '{}'", spec.gitRev, spec.git)); + } + } + } else { + // tag/rev: the declared ref is already a stable identity. + // If the lock already recorded this dep and the clone is present, + // we still use it; otherwise fall through to clone. + auto gitRoot = computeGitRoot(resolvedGitRev); + if (std::filesystem::exists(gitRoot / ".git")) { + mcpp::ui::info("Resolved", + std::format("{} ({} = {}) from cache", + spec.git, spec.gitRefKind, spec.gitRev)); } - std::istringstream is(r.output); - is >> resolvedGitRev; - if (resolvedGitRev.empty()) { + if (!std::filesystem::exists(gitRoot / ".git") && + mcpp::platform::env::offline_mode()) { return std::unexpected(std::format( - "git branch '{}' not found in '{}'", spec.gitRev, spec.git)); + "git dep '{}' is locked but its local cache is missing; " + "run without --offline to clone, or `mcpp update {}` to re-resolve.", + name, name)); } } diff --git a/src/pm/lock_io.cppm b/src/pm/lock_io.cppm index 3684b7cb..7c0f6da9 100644 --- a/src/pm/lock_io.cppm +++ b/src/pm/lock_io.cppm @@ -33,6 +33,22 @@ struct LockedPackage { std::string hash; // "sha256:..." or "fnv1a:..." }; +// Parsed form of a git source string as written to mcpp.lock. +// Supported forms: +// git+https://host/repo#branch=develop@5848943... +// git+https://host/repo#tag=v1.0.0 +// git+https://host/repo#rev=5848943... +// The resolvedCommit field is optional because old lock entries or +// tag/rev sources may not carry a resolved commit. +struct LockedGitSource { + std::string url; + std::string refKind; // "branch", "tag", or "rev" + std::string ref; + std::optional resolvedCommit; // for branch entries with @commit +}; + +std::optional parse_git_source(std::string_view source); + struct Lockfile { int schemaVersion = 2; std::vector indices; @@ -159,4 +175,37 @@ std::string compute_hash(const Lockfile& lock) { return std::format("{:016x}", h); } +std::optional parse_git_source(std::string_view source) { + constexpr std::string_view prefix = "git+"; + if (!source.starts_with(prefix)) return std::nullopt; + + auto rest = source.substr(prefix.size()); + auto hashPos = rest.find('#'); + if (hashPos == std::string_view::npos) return std::nullopt; + + LockedGitSource out; + out.url = std::string(rest.substr(0, hashPos)); + auto fragment = rest.substr(hashPos + 1); + + // fragment is one of: branch=develop@commit, tag=v1.0.0, rev=abc123 + auto eqPos = fragment.find('='); + if (eqPos == std::string_view::npos) return std::nullopt; + + out.refKind = std::string(fragment.substr(0, eqPos)); + if (out.refKind != "branch" && out.refKind != "tag" && out.refKind != "rev") + return std::nullopt; + + auto refPart = fragment.substr(eqPos + 1); + auto atPos = refPart.find('@'); + if (atPos == std::string_view::npos) { + out.ref = std::string(refPart); + } else { + out.ref = std::string(refPart.substr(0, atPos)); + out.resolvedCommit = std::string(refPart.substr(atPos + 1)); + } + + if (out.ref.empty()) return std::nullopt; + return out; +} + } // namespace mcpp::pm diff --git a/tests/e2e/24_git_dependency.sh b/tests/e2e/24_git_dependency.sh index be24ca26..2b0bc434 100755 --- a/tests/e2e/24_git_dependency.sh +++ b/tests/e2e/24_git_dependency.sh @@ -153,6 +153,11 @@ out=$(${triple}${fp_dir}/bin/branchapp) echo "FAIL: branch dep v1 not invoked: $out" cat branch-v1.log; exit 1; } +# Second build with the lock in place must not hit the network for ls-remote. +build2=$("$MCPP" build 2>&1) +echo "$build2" | grep -q 'ls-remote' && { echo "FAIL: branch dep re-ls-remoted on rebuild"; exit 1; } || true +echo "$build2" | grep -q 'Cloning' && { echo "FAIL: branch dep re-cloned on rebuild"; exit 1; } || true + grep -q 'source = "git+' mcpp.lock || { echo "FAIL: git dep lock source is not marked as git" cat mcpp.lock; exit 1; } diff --git a/tests/unit/test_pm_lock_io.cpp b/tests/unit/test_pm_lock_io.cpp new file mode 100644 index 00000000..74f5a612 --- /dev/null +++ b/tests/unit/test_pm_lock_io.cpp @@ -0,0 +1,56 @@ +#include + +import std; +import mcpp.pm.lock_io; + +TEST(PmLockIo, ParseGitBranchWithCommit) { + auto parsed = mcpp::pm::parse_git_source( + "git+https://github.com/user/repo#branch=develop@584894315b7a4fe4d7957d3c29dc4052b8012860"); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->url, "https://github.com/user/repo"); + EXPECT_EQ(parsed->refKind, "branch"); + EXPECT_EQ(parsed->ref, "develop"); + ASSERT_TRUE(parsed->resolvedCommit.has_value()); + EXPECT_EQ(parsed->resolvedCommit.value(), + "584894315b7a4fe4d7957d3c29dc4052b8012860"); +} + +TEST(PmLockIo, ParseGitBranchWithoutCommit) { + auto parsed = mcpp::pm::parse_git_source( + "git+https://github.com/user/repo#branch=develop"); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->url, "https://github.com/user/repo"); + EXPECT_EQ(parsed->refKind, "branch"); + EXPECT_EQ(parsed->ref, "develop"); + EXPECT_FALSE(parsed->resolvedCommit.has_value()); +} + +TEST(PmLockIo, ParseGitTag) { + auto parsed = mcpp::pm::parse_git_source( + "git+https://github.com/user/repo#tag=v1.0.0"); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->url, "https://github.com/user/repo"); + EXPECT_EQ(parsed->refKind, "tag"); + EXPECT_EQ(parsed->ref, "v1.0.0"); + EXPECT_FALSE(parsed->resolvedCommit.has_value()); +} + +TEST(PmLockIo, ParseGitRev) { + auto parsed = mcpp::pm::parse_git_source( + "git+https://github.com/user/repo#rev=584894315b7a4fe4d7957d3c29dc4052b8012860"); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->url, "https://github.com/user/repo"); + EXPECT_EQ(parsed->refKind, "rev"); + EXPECT_EQ(parsed->ref, "584894315b7a4fe4d7957d3c29dc4052b8012860"); + EXPECT_FALSE(parsed->resolvedCommit.has_value()); +} + +TEST(PmLockIo, ParseNonGitSourceReturnsNullopt) { + EXPECT_FALSE(mcpp::pm::parse_git_source("index+mcpplibs@1.0.0").has_value()); + EXPECT_FALSE(mcpp::pm::parse_git_source("").has_value()); + EXPECT_FALSE( + mcpp::pm::parse_git_source("https://github.com/user/repo").has_value()); + EXPECT_FALSE(mcpp::pm::parse_git_source("git+https://host/repo").has_value()); + EXPECT_FALSE( + mcpp::pm::parse_git_source("git+https://host/repo#bad").has_value()); +} From 05a6c5b4af86fae403e228b53f66b13286189f4a Mon Sep 17 00:00:00 2001 From: Zhe Feng Date: Sat, 1 Aug 2026 22:18:32 +0800 Subject: [PATCH 2/4] fix(pm): address PR #330 review feedback - tests: assert 'from lock' on rebuild instead of vacuous 'ls-remote' - prepare: dedupe cache-key formula via computeGitRoot(resolvedGitRev) - prepare: add anchor.url == spec.git to offline anchor match - prepare: drop 'mcpp update' from offline diagnostics (offline no-op) - commands: refresh stale comment in cmd_update about lockfile semantics - lock_io: split fragment on last '@' (branch names with '@') and reset empty resolvedCommit so the offline branch prints the right hint --- src/build/prepare.cppm | 23 +++++++++++------------ src/pm/commands.cppm | 17 +++++++++-------- src/pm/lock_io.cppm | 7 ++++++- tests/e2e/24_git_dependency.sh | 2 +- 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 674f1f4e..b5f22523 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -3020,7 +3020,9 @@ prepare_build(bool print_fingerprint, std::optional lockedCommit; if (auto it = gitLockAnchors.find(name); it != gitLockAnchors.end()) { auto const& anchor = it->second; - if (anchor.refKind == spec.gitRefKind && anchor.ref == spec.gitRev) { + if (anchor.refKind == spec.gitRefKind + && anchor.ref == spec.gitRev + && anchor.url == spec.git) { lockedCommit = anchor.resolvedCommit; } } @@ -3063,13 +3065,13 @@ prepare_build(bool print_fingerprint, if (lockedCommit) { return std::unexpected(std::format( "git dep '{}' locked to commit {} but its local cache is missing or stale; " - "run without --offline to refresh, or `mcpp update {}` to re-resolve", - name, *lockedCommit, name)); + "run without --offline to refresh.", + name, *lockedCommit)); } else { return std::unexpected(std::format( "git dep '{}' uses branch '{}' and mcpp.lock has no commit; " - "cannot resolve offline. Run `mcpp update {}` or build without --offline.", - name, spec.gitRev, name)); + "cannot resolve offline. Run without --offline.", + name, spec.gitRev)); } } auto cmd = std::format( @@ -3102,18 +3104,14 @@ prepare_build(bool print_fingerprint, mcpp::platform::env::offline_mode()) { return std::unexpected(std::format( "git dep '{}' is locked but its local cache is missing; " - "run without --offline to clone, or `mcpp update {}` to re-resolve.", - name, name)); + "run without --offline to clone.", + name)); } } // Cache key: hash(url + refkind + declared ref + resolved commit). // For fixed rev/tag deps the declared ref is also the resolved ref. - std::hash H; - auto urlHash = std::format("{:016x}", - H(spec.git + "|" + spec.gitRefKind + "|" + spec.gitRev - + "|" + resolvedGitRev)); - auto gitRoot = mcppHome / "git" / urlHash; + auto gitRoot = computeGitRoot(resolvedGitRev); std::error_code ec; std::filesystem::create_directories(gitRoot.parent_path(), ec); if (!std::filesystem::exists(gitRoot / ".git")) { @@ -3150,6 +3148,7 @@ prepare_build(bool print_fingerprint, } } if (item.consumerDepIndex == kMainConsumer) { + std::hash H; auto source = std::format("git+{}#{}={}", spec.git, spec.gitRefKind, spec.gitRev); if (spec.gitRefKind == "branch") source += "@" + resolvedGitRev; diff --git a/src/pm/commands.cppm b/src/pm/commands.cppm index 36d74caa..90edfd75 100644 --- a/src/pm/commands.cppm +++ b/src/pm/commands.cppm @@ -412,14 +412,15 @@ inline int cmd_update(const mcpplibs::cmdline::ParsedArgs& parsed) { // Refresh the index FIRST (#315/D6). // - // This command used to only drop lock entries and tell the user to run - // `mcpp build` — but the build path never reads mcpp.lock (prepare writes - // it and nothing on that path loads it), so the whole command was a no-op: - // it changed no behaviour whatsoever. It is also the only command whose - // stated purpose is "get me newer dependencies", which since #315 is - // exactly what an index refresh is for. Explicit intent, so no debounce and - // no TTL — but still refused when offline, loudly, rather than silently - // doing nothing again. + // Since #330 the build path reads mcpp.lock to short-circuit `git + // ls-remote` for branch deps that are already resolved to a commit + // with a matching local cache. `mcpp update ` therefore no + // longer "refreshes" a git branch dep — it only drops the lock + // entry, which forces the next build to call `ls-remote` again. + // The index refresh below is unrelated: it covers the registry-served + // version deps only. Skipped when offline — loudly, not silently — + // since #315 is exactly about not paying a network round-trip to + // achieve nothing. // Skipped when nothing in the project is served by the shared registry: // syncing it does nothing for path deps, git deps or a project `[indices]` // entry, and paying a multi-repo network round-trip to achieve nothing is diff --git a/src/pm/lock_io.cppm b/src/pm/lock_io.cppm index 7c0f6da9..f0c71081 100644 --- a/src/pm/lock_io.cppm +++ b/src/pm/lock_io.cppm @@ -196,12 +196,17 @@ std::optional parse_git_source(std::string_view source) { return std::nullopt; auto refPart = fragment.substr(eqPos + 1); - auto atPos = refPart.find('@'); + // Split on the LAST `@` so branch names containing `@` (e.g. "feat@v2") + // match the write format in prepare.cppm (`ref + "@" + commit`). + auto atPos = refPart.rfind('@'); if (atPos == std::string_view::npos) { out.ref = std::string(refPart); } else { out.ref = std::string(refPart.substr(0, atPos)); out.resolvedCommit = std::string(refPart.substr(atPos + 1)); + // A bare `branch=foo@` would otherwise yield an empty commit and + // confuse the offline anchor into reporting "locked to commit ". + if (out.resolvedCommit->empty()) out.resolvedCommit.reset(); } if (out.ref.empty()) return std::nullopt; diff --git a/tests/e2e/24_git_dependency.sh b/tests/e2e/24_git_dependency.sh index 2b0bc434..dea34ba7 100755 --- a/tests/e2e/24_git_dependency.sh +++ b/tests/e2e/24_git_dependency.sh @@ -155,7 +155,7 @@ out=$(${triple}${fp_dir}/bin/branchapp) # Second build with the lock in place must not hit the network for ls-remote. build2=$("$MCPP" build 2>&1) -echo "$build2" | grep -q 'ls-remote' && { echo "FAIL: branch dep re-ls-remoted on rebuild"; exit 1; } || true +echo "$build2" | grep -q 'from lock' || { echo "FAIL: branch dep not resolved from lock on rebuild"; cat <<<"$build2"; exit 1; } echo "$build2" | grep -q 'Cloning' && { echo "FAIL: branch dep re-cloned on rebuild"; exit 1; } || true grep -q 'source = "git+' mcpp.lock || { From 5218f8591152d4f9d9d9a1da9d2a4c9d61a95c9e Mon Sep 17 00:00:00 2001 From: Zhe Feng Date: Sat, 1 Aug 2026 22:36:09 +0800 Subject: [PATCH 3/4] fix(e2e): force prepare_build for the 24_git_dependency lock-anchor check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare second `mcpp build` takes the try_fast_build path when build.ninja is fresh (no source change), so prepare_build is skipped and neither the lock-anchor code nor `git ls-remote` runs — leaving the `from lock` assertion vacuous (it cannot match; same failure mode as the original ls-remote assertion the reviewer flagged). Run `mcpp clean` first so the fast-path cannot fire; mcpp.lock and the git cache (in MCPP_HOME/git) both survive `mcpp clean`, so the next build re-prepares, hits the anchor, prints 'from lock' and skips both ls-remote and the clone. --- tests/e2e/24_git_dependency.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/e2e/24_git_dependency.sh b/tests/e2e/24_git_dependency.sh index dea34ba7..2a51518a 100755 --- a/tests/e2e/24_git_dependency.sh +++ b/tests/e2e/24_git_dependency.sh @@ -153,9 +153,17 @@ out=$(${triple}${fp_dir}/bin/branchapp) echo "FAIL: branch dep v1 not invoked: $out" cat branch-v1.log; exit 1; } -# Second build with the lock in place must not hit the network for ls-remote. +# Second build with the lock in place must resolve the branch dep from the +# lock commit rather than calling `git ls-remote`. A bare `mcpp build` here +# would take the prepare_build fast-path (build.ninja is fresh → just run +# ninja → "Finished" only), so prepare_build never runs and neither the lock +# anchor path nor `ls-remote` is exercised — leaving the assertion vacuous +# (the original main-branch test passed identically). `mcpp clean` wipes only +# `target/`, so mcpp.lock and the git cache (~/.mcpp/git) survive; the next +# build must re-prepare, hit the anchor, and skip both ls-remote and clone. +"$MCPP" clean >/dev/null build2=$("$MCPP" build 2>&1) -echo "$build2" | grep -q 'from lock' || { echo "FAIL: branch dep not resolved from lock on rebuild"; cat <<<"$build2"; exit 1; } +echo "$build2" | grep -q 'from lock' || { echo "FAIL: branch dep not resolved from lock on rebuild"; echo "--- build2 ---"; cat <<<"$build2"; exit 1; } echo "$build2" | grep -q 'Cloning' && { echo "FAIL: branch dep re-cloned on rebuild"; exit 1; } || true grep -q 'source = "git+' mcpp.lock || { From 5f9d3e12c12e0f8ede2a11e9755dc683eae0c838 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 1 Aug 2026 23:45:23 +0800 Subject: [PATCH 4/4] refactor(pm): make mcpp.lock authoritative for git deps (2026.8.1.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock was read for the first time in the previous commits of this PR, but only as a hint the local clone had to confirm: a recorded commit counted while `~/.mcpp/git/` existed, and otherwise the branch was re-resolved over the network and the lock rewritten. That hands "which commit do we build" to the survival of a cache directory. `mcpp new` does not gitignore `mcpp.lock` — it is meant to be committed — so the failure is ordinary: clone the project on a second machine, get a different commit, and see the lock quietly change under you. Measured on the new e2e assertion, with the branch moved and the cache evicted: the previous head built v2, this builds the v1 the lock recorded. `cargo build` / `cargo update` split it the same way. Making the lock authoritative also collapses the block into two independent questions, each with at most one network operation and therefore exactly one --offline gate: which commit (tag/rev name one; a branch is answered by the lock, else by ls-remote), and is it on disk (the commit picks the cache directory; a miss is a clone). The separate tag/rev leg goes with it, taking its per-build "from cache" noise line and its "is locked but its local cache is missing" message — which was emitted for deps that were never in the lock. Four defects fixed along the way: - --offline refused git remotes that are local directories. docs/05-mcpp-toml.md defines --offline as "never touch the network ... anything already installed still builds", and prepare.cppm's dependency-download gate draws the same line in a comment; ls-remote/clone against a local path are filesystem reads, so refusing them buys no isolation. Remotes are now classified by shape, which keeps a Windows drive letter (C:\repo — colon, no @) on the local side. - A clone killed between `git clone` and `git checkout` served the wrong commit forever: the directory is named after the commit but was only checked for existence. Branch deps now compare `git rev-parse HEAD` and re-clone on mismatch (tag/rev keep the ref name as identity, so there is nothing to compare). - The clone depended on `cd` changing drive on Windows. cmd.exe needs `cd /d`, and MCPP_HOME routinely sits on a different drive than the project; every other cross-drive site in the repo (process.cppm, msvc.cppm) writes /d, this one did not. `git -C ` needs no shell at all. - An unreadable mcpp.lock was a plain warning, invisible to --strict. Per src/diag.cppm that is the degraded channel: the engine silently does less than asked — every git branch dep falls back to the network. Also: parse_git_source no longer splits a tag/rev whose name contains '@' (only branch entries carry @, which is what the writer emits), the cmd_update comment now records the right causality, and docs/CHANGELOG cover the new semantics in both languages. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 22 +++ docs/05-mcpp-toml.md | 24 ++- docs/zh/05-mcpp-toml.md | 20 ++- mcpp.toml | 2 +- src/build/prepare.cppm | 294 +++++++++++++++++++-------------- src/pm/commands.cppm | 19 ++- src/pm/lock_io.cppm | 31 ++-- src/toolchain/fingerprint.cppm | 2 +- tests/e2e/24_git_dependency.sh | 46 ++++-- tests/unit/test_pm_lock_io.cpp | 51 ++++++ 10 files changed, 349 insertions(+), 162 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4eb5c3..1f68c716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.1.2] — 2026-08-01 + +### 新增 + +- **`mcpp.lock` 成为 git 依赖的解析输入,而且是权威的那一份。** 在此之前 lock 只写不读:`branch = "develop"` 这类依赖每次进 `prepare_build` 都要打一次 `git ls-remote`,而写进 lock 的那个 commit 从来没有人看过。现在**先读 lock**——分支已经解析过就直接用记录的 commit,不再有网络往返。 + + 「权威」是这里的关键词,不是「缓存的提示」。一个只在本地克隆恰好还在时才生效的 lock,等于把「构建哪个 commit」这个决定交给了缓存目录的存活状况:清一次 `~/.mcpp/git`、或者换一台机器 clone 同一个工程,构建就会静默滑到分支的新头上,并把 lock 改写成新值——**而 `mcpp new` 生成的 `.gitignore` 并不忽略 `mcpp.lock`,它本来就是要提交的**。实测(见 e2e 24 新增断言):分支移到 v2、缓存删除后,旧行为构建出 v2,新行为构建出 lock 里记的 v1。要新的分支头依然只有一条路——`mcpp update `,它丢掉 lock 条目,下次构建重新解析。 + + 连带把这块的结构收敛成两个各自独立的问题,各带**恰好一个** `--offline` 闸:**(1) 是哪个 commit** —— `tag`/`rev` 自带答案,`branch` 由 lock 回答、否则 `ls-remote`;**(2) 它在不在盘上** —— commit 选定缓存目录,miss 就 clone。原来 tag/rev 有一条自己的分支腿、打一行每次构建都出现的 `from cache` 噪声,并且在 dep 根本没进 lock 时报「is locked but its local cache is missing」;这条腿现在整个不需要了。 + +### 修复 + +- **`--offline` 不再拒绝本地 git 远端。** `docs/05-mcpp-toml.md` 写明 `--offline` 的语义是「完全不碰网络……已安装的东西照常构建」,`prepare.cppm` 里依赖下载闸的注释也把线画在同一处:「这条线以上全是本地操作,一个依赖齐备的离线构建必须成功」。但 `git = "../sibling-repo"` 这种指向本地目录的远端,`ls-remote`/`clone` 都只是文件系统读取,拒绝它买不到任何隔离性。现在按远端形态判定——`file://`、以及不带 scheme 也不是 `git@host:path` 的存在路径,算本地(Windows 盘符 `C:\repo` 含冒号但不含 `@`,因此仍归本地)。 + +- **克隆被中途杀掉后不再永久提供错误的 commit。** 缓存目录以 commit 命名,但内容是 `git clone` 之后再 `git checkout` 两步做出来的;进程死在两步之间,目录名和 HEAD 就对不上了,而后续构建只检查目录存不存在。现在分支依赖会比对 `git rev-parse HEAD`,不符即删除重克隆(tag/rev 以 ref 名为身份,无可比之物)。 + +- **git 克隆在 Windows 上不再依赖 `cd` 跨盘。** 克隆命令用的是 `git clone … && cd && git checkout`,而 cmd.exe 的 `cd` 不带 `/d` 是不换盘的——缓存根(`MCPP_HOME`)与工程不同盘是常态。仓库里其它跨盘位置(`process.cppm`、`msvc.cppm`)都写了 `cd /d`,这里漏了。改用 `git -C `,连 shell 都不需要。 + +- **`mcpp.lock` 读取失败改用 degraded 通道。** 按 `src/diag.cppm` 的划分,「引擎做得比要求的少」属于 Degraded 且必须给出 impact——lock 读不出来,每个 git 分支依赖都会退回网络解析、并可能越过记录的 commit。原来记的是普通 warning,`--strict` 提升不到它。 + +- **`parse_git_source` 不再切开名字里带 `@` 的 tag/rev。** 写侧只对 `branch` 追加 `@`,解析侧却无条件按最后一个 `@` 切分,于是一个叫 `v1.0@rc1` 的 tag 会被读成 ref `v1.0` + commit `rc1`。现在只有 branch 走这条切分(且仍按最后一个 `@`,以容纳 `feat@v2` 这类分支名)。 + ## [2026.8.1.1] — 2026-08-01 ### 修复 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index b8f6d121..a64fd401 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -339,9 +339,10 @@ glfw = "3.4" # Explicit namespace, skips the mcpplibs-first c [dependencies] mylib = { path = "../mylib" } -# Git dependency +# Git dependency — pick exactly one of tag / branch / rev [dependencies] mylib = { git = "https://github.com/user/mylib.git", tag = "v1.0.0" } +applib = { git = "https://github.com/user/applib.git", branch = "develop" } # Long-form dep spec: features and backend knobs [dependencies] @@ -356,6 +357,25 @@ package declares `[features]` but does not include the requested feature (includ the result of backend desugaring), a warning is issued by default, and an error under `mcpp build --strict`. +**Git dependencies and `mcpp.lock`**: a `tag` or `rev` already names a fixed point +in history, but a `branch` moves. The first build resolves the branch to a commit +and records it in `mcpp.lock`, and every later build rebuilds **that** commit — the +lock is authoritative, not a cache hint, so deleting `~/.mcpp/git` or moving to +another machine cannot quietly put you on a newer tip. Ask for the newer tip +explicitly: + +```bash +mcpp update mylib # drop the recorded commit; the next build re-resolves it +mcpp update # same, for every dependency +``` + +Because the recorded commit is enough to decide what to build, a rebuild with the +clone already in `~/.mcpp/git` makes no network request at all and works under +`--offline`. Only two things need the network: resolving a branch that has no +commit in the lock, and cloning a commit that is not cached yet. A `git =` value +that names a local directory (or a `file://` URL) needs neither, so it is never +refused offline. + **SemVer constraints**: ```toml @@ -454,7 +474,7 @@ Controls, in order of precedence: | Control | Effect | |---|---| -| `--offline` (any command) | Never touch the network — no index refresh, no downloads, no toolchain auto-install. Anything already installed still builds | +| `--offline` (any command) | Never touch the network — no index refresh, no downloads, no toolchain auto-install, no `git ls-remote`/`clone`. Anything already installed still builds, including git deps whose commit is in `mcpp.lock` and whose clone is cached | | `MCPP_OFFLINE=1` | Same, for a whole shell session or CI job | | `[index] auto_refresh = false` in `~/.mcpp/config.toml` | Never refresh the index automatically; downloads still work | diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 367cfae5..1e641d11 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -310,9 +310,10 @@ glfw = "3.4" # 显式 namespace, 不走 mcpplibs 优先候选 [dependencies] mylib = { path = "../mylib" } -# Git 依赖 +# Git 依赖 —— tag / branch / rev 三选一 [dependencies] mylib = { git = "https://github.com/user/mylib.git", tag = "v1.0.0" } +applib = { git = "https://github.com/user/applib.git", branch = "develop" } # 长式 dep spec:features 与 backend 旋钮 [dependencies] @@ -325,6 +326,21 @@ feature(库若支持该旋钮,应在自己的 `[features]` 中声明 `backend-*` 若目标包声明了 `[features]` 但不含所请求的 feature(含 backend 脱糖结果), 默认给出 warning,`mcpp build --strict` 下报错。 +**Git 依赖与 `mcpp.lock`**:`tag` 和 `rev` 本身就指向历史中的固定点,而 `branch` +是会动的。首次构建把分支解析成一个 commit 并写进 `mcpp.lock`,此后每次构建都重建 +**那个** commit —— lock 是权威而不是缓存提示,所以删掉 `~/.mcpp/git` 或换一台机器 +都不会悄悄把你挪到更新的分支头上。要新的分支头,得显式要: + +```bash +mcpp update mylib # 丢掉记录的 commit,下次构建重新解析 +mcpp update # 同上,对所有依赖 +``` + +既然记录的 commit 已经足以决定构建什么,那么在 `~/.mcpp/git` 里已有克隆的情况下, +重新构建完全不发网络请求,`--offline` 下照常工作。只有两件事需要网络:解析一个在 +lock 里没有 commit 的分支,以及克隆一个尚未缓存的 commit。`git =` 若指向本地目录 +(或 `file://` URL),这两件事都不需要网络,因此离线下也绝不会被拒绝。 + **SemVer 约束**: ```toml @@ -443,7 +459,7 @@ mcpp index status # 看本地现状:状态、年龄、修订号 | 开关 | 作用 | |---|---| -| `--offline`(任意命令) | 完全不碰网络——不刷索引、不下载、不自动装工具链。已安装的东西照常构建 | +| `--offline`(任意命令) | 完全不碰网络——不刷索引、不下载、不自动装工具链,也不发 `git ls-remote`/`clone`。已安装的东西照常构建,包括 commit 已在 `mcpp.lock`、克隆已在缓存里的 git 依赖 | | `MCPP_OFFLINE=1` | 同上,作用于整个 shell 会话或 CI job | | `~/.mcpp/config.toml` 里 `[index] auto_refresh = false` | 永不自动刷新索引,但下载仍然可用 | diff --git a/mcpp.toml b/mcpp.toml index 685d5c4e..3533601f 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.1.1" +version = "2026.8.1.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index b5f22523..0ca7bea9 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -710,6 +710,50 @@ export struct BuildOverrides { std::string cache_mode; // --cache global|local|off ("" = unset) }; +// ── git dependency helpers ────────────────────────────────────────────────── + +// Is this git remote reachable without a network round-trip? +// +// `--offline` means "never touch the network" (docs/05-mcpp-toml.md), and its +// standing promise is that anything already on disk still builds. A remote that +// names a local directory — or a file:// URL — is served by plain filesystem +// reads, so refusing it would break that promise without buying any isolation. +// The dependency-download gate further down draws the same line. +// +// Recognising a scheme (`https://`, `ssh://`, `git://`) or scp-like syntax +// (`git@host:path`) as remote first keeps a Windows drive letter (`C:\repo`, +// which contains a colon but no `@`) on the local side. +bool is_local_git_remote(std::string_view url) { + if (url.starts_with("file://")) return true; + if (url.contains("://")) return false; + if (url.contains('@') && url.contains(':')) return false; + std::error_code ec; + return std::filesystem::exists(std::filesystem::path(url), ec); +} + +// The commit a cached clone is actually parked on, or "" if it cannot be read. +// +// Used to detect a clone that was interrupted between `git clone` and +// `git checkout` — the directory exists and looks like a repository, but sits +// on the wrong commit. Only meaningful when the expected revision is a sha, +// i.e. for branch deps after resolution. +// +// stderr is folded in so a git warning cannot leak to the user's terminal; +// the last line is taken so such a warning cannot corrupt the sha either. +std::string git_cache_head(const std::filesystem::path& gitRoot) { + auto r = mcpp::platform::process::capture(std::format( + "git -C {} rev-parse HEAD 2>&1", + mcpp::platform::shell::quote(gitRoot.string()))); + if (r.exit_code != 0) return {}; + std::string out = r.output; + while (!out.empty() && (out.back() == '\n' || out.back() == '\r' + || out.back() == ' ' || out.back() == '\t')) + out.pop_back(); + if (auto nl = out.find_last_of("\r\n"); nl != std::string::npos) + out.erase(0, nl + 1); + return out; +} + // `prepare_build` builds the BuildContext for any verb that compiles. // includeDevDeps: when true, dev-dependencies are also fetched + scanned // into the modgraph. mcpp test passes true; build/run pass false. @@ -839,22 +883,28 @@ prepare_build(bool print_fingerprint, mcpp::diag::warning("manifest/schema", w); } - // Load mcpp.lock once. Git-based deps can use it as an offline anchor: - // if a branch is already resolved to a commit and the local cache matches, - // no network round-trip is needed. + // Load mcpp.lock once, up front: it is a resolution input for git deps + // (#329), which decide the commit to build long before anything is + // fetched. Keyed by package name — the same key the writer at the end of + // this function emits, both taken from the root manifest's [dependencies]. std::map gitLockAnchors; { auto lockPath = *root / "mcpp.lock"; if (std::filesystem::exists(lockPath)) { - if (auto lock = mcpp::lockfile::load(lockPath); lock) { - for (auto const& p : lock->packages) { - if (auto parsed = mcpp::pm::parse_git_source(p.source); parsed) { + if (auto lock = mcpp::pm::load(lockPath); lock) { + for (auto const& p : lock->packages) + if (auto parsed = mcpp::pm::parse_git_source(p.source); parsed) gitLockAnchors.emplace(p.name, std::move(*parsed)); - } - } } else { - mcpp::diag::warning("lockfile", - std::format("ignoring mcpp.lock: {}", lock.error().message)); + // Degraded, not a plain warning: the engine silently does less + // than asked — every git branch dep falls back to `ls-remote` + // and may advance past the commit the lock recorded. + mcpp::diag::degraded("lockfile", + std::format("mcpp.lock could not be read: {}", + lock.error().message), + "git branch dependencies are re-resolved over the network " + "and may move onto a newer commit than the one recorded", + "delete mcpp.lock and rebuild to regenerate it"); } } } @@ -3004,151 +3054,147 @@ prepare_build(bool print_fingerprint, dep_root = std::filesystem::weakly_canonical(dep_root); } else if (spec.isGit()) { // Git-based (M4 #5): clone into ~/.mcpp/git// and treat - // as a path dep from there. Branch refs are floating, so resolve - // them to a commit before forming the cache key; this lets - // `mcpp update ` pick up a moved branch without deleting - // unrelated git caches. + // as a path dep from there. + // + // Two independent questions, each answered by at most one network + // operation and therefore guarded by exactly one --offline gate: + // + // 1. WHICH COMMIT? `tag`/`rev` name one outright. A `branch` is + // floating: mcpp.lock answers it, else `git ls-remote` does. + // 2. IS IT ON DISK? The commit selects the cache directory, so a + // miss — or a clone parked on the wrong commit — is a clone. // - // mcpp.lock acts as an offline anchor: if a branch is already - // resolved to a commit and the local cache still matches it, - // we skip the network round-trip entirely. + // mcpp.lock is authoritative for (1), not a hint that (2) has to + // confirm: a recorded commit is used whether or not the clone + // survived, so evicting ~/.mcpp/git can never quietly move a build + // onto a newer branch tip. `mcpp update ` drops the entry and + // stays the one way a branch advances. auto mcppHome = mcpp::home::root(); // single resolver (#311) - std::string resolvedGitRev = spec.gitRev; - bool skipLsRemote = false; - - // Look up an offline anchor for this git dep. - std::optional lockedCommit; - if (auto it = gitLockAnchors.find(name); it != gitLockAnchors.end()) { - auto const& anchor = it->second; - if (anchor.refKind == spec.gitRefKind - && anchor.ref == spec.gitRev - && anchor.url == spec.git) { - lockedCommit = anchor.resolvedCommit; - } - } - auto computeGitRoot = [&](const std::string& rev) { - std::hash H; - auto urlHash = std::format("{:016x}", - H(spec.git + "|" + spec.gitRefKind + "|" + spec.gitRev - + "|" + rev)); - return mcppHome / "git" / urlHash; - }; - - auto readCacheHead = [&](const std::filesystem::path& gitRoot) - -> std::string - { - auto cmd = std::format("git -C {} rev-parse HEAD 2>&1", - mcpp::platform::shell::quote(gitRoot.string())); - auto r = mcpp::platform::process::capture(cmd); - if (r.exit_code != 0) return {}; - std::string head = r.output; - head.erase(head.find_last_not_of(" \r\n\t") + 1); - return head; + const bool remoteIsLocal = is_local_git_remote(spec.git); + auto refuse_offline = [&](std::string_view need, + std::string_view why, + std::string_view verb) { + return std::unexpected(std::format( + "offline mode: git dependency '{}' needs {} of '{}'\n" + " {}\n" + " run without --offline (or unset MCPP_OFFLINE) to {} it", + name, need, spec.git, why, verb)); }; + const bool offline = + !remoteIsLocal && mcpp::platform::env::offline_mode(); + // ── 1. which commit ── + std::string resolvedGitRev = spec.gitRev; + bool fromLock = false; if (spec.gitRefKind == "branch") { - auto ref = std::format("refs/heads/{}", spec.gitRev); - if (lockedCommit && !lockedCommit->empty()) { - resolvedGitRev = *lockedCommit; - auto gitRoot = computeGitRoot(resolvedGitRev); - if (std::filesystem::exists(gitRoot / ".git") && - readCacheHead(gitRoot) == resolvedGitRev) { - skipLsRemote = true; - mcpp::ui::info("Resolved", - std::format("{} ({} = {}) from lock", - spec.git, spec.gitRefKind, spec.gitRev)); - } - } - if (!skipLsRemote) { - if (mcpp::platform::env::offline_mode()) { - if (lockedCommit) { - return std::unexpected(std::format( - "git dep '{}' locked to commit {} but its local cache is missing or stale; " - "run without --offline to refresh.", - name, *lockedCommit)); - } else { - return std::unexpected(std::format( - "git dep '{}' uses branch '{}' and mcpp.lock has no commit; " - "cannot resolve offline. Run without --offline.", - name, spec.gitRev)); - } - } - auto cmd = std::format( + auto it = gitLockAnchors.find(name); + if (it != gitLockAnchors.end() + && it->second.url == spec.git + && it->second.refKind == spec.gitRefKind + && it->second.ref == spec.gitRev + && it->second.resolvedCommit) { + resolvedGitRev = *it->second.resolvedCommit; + fromLock = true; + } else { + if (offline) + return refuse_offline("`git ls-remote`", + std::format("mcpp.lock records no commit for branch " + "'{}'", spec.gitRev), + "resolve"); + auto r = mcpp::platform::process::capture(std::format( "git ls-remote {} {} 2>&1", mcpp::platform::shell::quote(spec.git), - mcpp::platform::shell::quote(ref)); - auto r = mcpp::platform::process::capture(cmd); - if (r.exit_code != 0) { + mcpp::platform::shell::quote( + std::format("refs/heads/{}", spec.gitRev)))); + if (r.exit_code != 0) return std::unexpected(std::format( - "git ls-remote of '{}' failed:\n{}", spec.git, r.output)); - } + "git ls-remote of '{}' failed:\n{}", + spec.git, r.output)); + // Cleared first: `operator>>` leaves the target untouched + // when the stream is already at EOF, which would otherwise + // let the declared branch name pass the emptiness check. + resolvedGitRev.clear(); std::istringstream is(r.output); is >> resolvedGitRev; - if (resolvedGitRev.empty()) { + if (resolvedGitRev.empty()) return std::unexpected(std::format( - "git branch '{}' not found in '{}'", spec.gitRev, spec.git)); - } - } - } else { - // tag/rev: the declared ref is already a stable identity. - // If the lock already recorded this dep and the clone is present, - // we still use it; otherwise fall through to clone. - auto gitRoot = computeGitRoot(resolvedGitRev); - if (std::filesystem::exists(gitRoot / ".git")) { - mcpp::ui::info("Resolved", - std::format("{} ({} = {}) from cache", - spec.git, spec.gitRefKind, spec.gitRev)); - } - if (!std::filesystem::exists(gitRoot / ".git") && - mcpp::platform::env::offline_mode()) { - return std::unexpected(std::format( - "git dep '{}' is locked but its local cache is missing; " - "run without --offline to clone.", - name)); + "git branch '{}' not found in '{}'", + spec.gitRev, spec.git)); } } + // ── 2. is it on disk ── // Cache key: hash(url + refkind + declared ref + resolved commit). // For fixed rev/tag deps the declared ref is also the resolved ref. - auto gitRoot = computeGitRoot(resolvedGitRev); + std::hash H; + auto gitRoot = mcppHome / "git" / std::format("{:016x}", + H(spec.git + "|" + spec.gitRefKind + "|" + spec.gitRev + + "|" + resolvedGitRev)); std::error_code ec; std::filesystem::create_directories(gitRoot.parent_path(), ec); - if (!std::filesystem::exists(gitRoot / ".git")) { + + // A branch's resolved rev is always a sha by now, so the clone can + // be checked against it — catching one killed between `git clone` + // and `git checkout`, which would otherwise serve the wrong commit + // from a correctly-named directory forever. tag/rev keep their ref + // name as the identity, so there is nothing to compare. + bool cachePresent = std::filesystem::exists(gitRoot / ".git"); + if (cachePresent && spec.gitRefKind == "branch" + && git_cache_head(gitRoot) != resolvedGitRev) { + std::filesystem::remove_all(gitRoot, ec); + cachePresent = false; + } + + // Reported before the clone, not instead of it: when the cache is + // gone this line is the whole explanation for why the build is on + // an older commit than the branch now points at. + if (fromLock) + mcpp::ui::info("Resolved", + std::format("{} (branch = {}) from mcpp.lock", + spec.git, spec.gitRev)); + + if (!cachePresent) { + if (offline) + return refuse_offline("a clone", + std::format("no cached clone at {}", gitRoot.string()), + "fetch"); mcpp::ui::info("Cloning", std::format("{} ({} = {})", spec.git, spec.gitRefKind, spec.gitRev)); - std::string cloneCmd; - if (spec.gitRefKind == "branch") { - cloneCmd = std::format( - "git clone --depth 1 --branch {} {} {} && cd {} && git checkout --quiet {} 2>&1", + // A commit taken from the lock may sit behind the branch tip, + // and a tag/rev may sit anywhere in history — both need full + // history before the checkout. Only a tip just read from + // ls-remote is guaranteed present in a depth-1 clone. + // + // `git -C` rather than `cd &&`: on Windows `cd` does not + // change drive without /d, and the cache root routinely lives + // on a different one than the project. + auto cloneCmd = (spec.gitRefKind == "branch" && !fromLock) + ? std::format( + "git clone --depth 1 --branch {} {} {} && " + "git -C {} checkout --quiet {} 2>&1", mcpp::platform::shell::quote(spec.gitRev), mcpp::platform::shell::quote(spec.git), mcpp::platform::shell::quote(gitRoot.string()), mcpp::platform::shell::quote(gitRoot.string()), - mcpp::platform::shell::quote(resolvedGitRev)); - } else { - // For tag/rev: full clone, then checkout (depth-1 may miss the rev). - cloneCmd = std::format( - "git clone {} {} && cd {} && git checkout --quiet {} 2>&1", + mcpp::platform::shell::quote(resolvedGitRev)) + : std::format( + "git clone {} {} && git -C {} checkout --quiet {} 2>&1", mcpp::platform::shell::quote(spec.git), mcpp::platform::shell::quote(gitRoot.string()), mcpp::platform::shell::quote(gitRoot.string()), - mcpp::platform::shell::quote(spec.gitRev)); - } - std::string out; - { - auto r = mcpp::platform::process::capture(cloneCmd); - out = r.output; - int rc = r.exit_code; - if (rc != 0) { - std::filesystem::remove_all(gitRoot, ec); - return std::unexpected(std::format( - "git clone of '{}' failed:\n{}", spec.git, out)); - } + mcpp::platform::shell::quote(resolvedGitRev)); + auto r = mcpp::platform::process::capture(cloneCmd); + if (r.exit_code != 0) { + std::filesystem::remove_all(gitRoot, ec); + return std::unexpected(std::format( + "git clone of '{}' failed:\n{}", spec.git, r.output)); } } if (item.consumerDepIndex == kMainConsumer) { - std::hash H; + // Only root deps are locked: the writer below walks the root + // manifest's [dependencies], so a transitive git branch dep + // has no anchor and still resolves over the network. auto source = std::format("git+{}#{}={}", spec.git, spec.gitRefKind, spec.gitRev); if (spec.gitRefKind == "branch") source += "@" + resolvedGitRev; diff --git a/src/pm/commands.cppm b/src/pm/commands.cppm index 90edfd75..f0ff07c7 100644 --- a/src/pm/commands.cppm +++ b/src/pm/commands.cppm @@ -412,15 +412,16 @@ inline int cmd_update(const mcpplibs::cmdline::ParsedArgs& parsed) { // Refresh the index FIRST (#315/D6). // - // Since #330 the build path reads mcpp.lock to short-circuit `git - // ls-remote` for branch deps that are already resolved to a commit - // with a matching local cache. `mcpp update ` therefore no - // longer "refreshes" a git branch dep — it only drops the lock - // entry, which forces the next build to call `ls-remote` again. - // The index refresh below is unrelated: it covers the registry-served - // version deps only. Skipped when offline — loudly, not silently — - // since #315 is exactly about not paying a network round-trip to - // achieve nothing. + // Until #329 this command changed nothing at all: it dropped lock + // entries and told the user to run `mcpp build`, but the build path + // never read mcpp.lock. #329 made the lock authoritative for git + // branch deps — `mcpp build` now deliberately rebuilds the recorded + // commit — so dropping an entry here is the one thing that lets a + // branch advance. That is half of what this command does; the index + // refresh below is the other half, covering registry-served version + // deps. Explicit intent, so no debounce and no TTL — but still + // refused when offline, loudly, rather than silently doing nothing + // again. // Skipped when nothing in the project is served by the shared registry: // syncing it does nothing for path deps, git deps or a project `[indices]` // entry, and paying a multi-repo network round-trip to achieve nothing is diff --git a/src/pm/lock_io.cppm b/src/pm/lock_io.cppm index f0c71081..a40a50a2 100644 --- a/src/pm/lock_io.cppm +++ b/src/pm/lock_io.cppm @@ -38,8 +38,10 @@ struct LockedPackage { // git+https://host/repo#branch=develop@5848943... // git+https://host/repo#tag=v1.0.0 // git+https://host/repo#rev=5848943... -// The resolvedCommit field is optional because old lock entries or -// tag/rev sources may not carry a resolved commit. +// Only `branch` carries `@`: a tag or rev already names a fixed point +// in history, whereas a branch is floating and the recorded commit is what +// pins it. `resolvedCommit` is therefore empty for tag/rev, and also for a +// branch entry written before the commit was known. struct LockedGitSource { std::string url; std::string refKind; // "branch", "tag", or "rev" @@ -196,17 +198,20 @@ std::optional parse_git_source(std::string_view source) { return std::nullopt; auto refPart = fragment.substr(eqPos + 1); - // Split on the LAST `@` so branch names containing `@` (e.g. "feat@v2") - // match the write format in prepare.cppm (`ref + "@" + commit`). - auto atPos = refPart.rfind('@'); - if (atPos == std::string_view::npos) { - out.ref = std::string(refPart); - } else { - out.ref = std::string(refPart.substr(0, atPos)); - out.resolvedCommit = std::string(refPart.substr(atPos + 1)); - // A bare `branch=foo@` would otherwise yield an empty commit and - // confuse the offline anchor into reporting "locked to commit ". - if (out.resolvedCommit->empty()) out.resolvedCommit.reset(); + out.ref = std::string(refPart); + // Only branch entries carry `@` — see the writer in prepare.cppm, + // which appends it for `branch` and nothing else. Splitting unconditionally + // would read a tag legitimately named `v1@rc` as ref `v1` plus commit `rc`. + // Within branch entries, split on the LAST `@` so a branch name that + // contains one ("feat@v2") still round-trips. + if (out.refKind == "branch") { + if (auto atPos = refPart.rfind('@'); atPos != std::string_view::npos) { + out.ref = std::string(refPart.substr(0, atPos)); + // A bare `branch=foo@` records no commit; leaving an empty string + // behind would make the anchor claim a pin it does not have. + if (auto commit = refPart.substr(atPos + 1); !commit.empty()) + out.resolvedCommit = std::string(commit); + } } if (out.ref.empty()) return std::nullopt; diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 93890ca1..987c9c7f 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "2026.8.1.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.1.2"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/24_git_dependency.sh b/tests/e2e/24_git_dependency.sh index 2a51518a..bfbce6d4 100755 --- a/tests/e2e/24_git_dependency.sh +++ b/tests/e2e/24_git_dependency.sh @@ -153,17 +153,24 @@ out=$(${triple}${fp_dir}/bin/branchapp) echo "FAIL: branch dep v1 not invoked: $out" cat branch-v1.log; exit 1; } -# Second build with the lock in place must resolve the branch dep from the -# lock commit rather than calling `git ls-remote`. A bare `mcpp build` here -# would take the prepare_build fast-path (build.ninja is fresh → just run -# ninja → "Finished" only), so prepare_build never runs and neither the lock -# anchor path nor `ls-remote` is exercised — leaving the assertion vacuous -# (the original main-branch test passed identically). `mcpp clean` wipes only -# `target/`, so mcpp.lock and the git cache (~/.mcpp/git) survive; the next -# build must re-prepare, hit the anchor, and skip both ls-remote and clone. +# A rebuild with the lock in place must resolve the branch dep from the +# recorded commit rather than calling `git ls-remote`. +# +# `mcpp clean` first: a bare `mcpp build` here would take the fast path in +# cmd_build (build.ninja is newer than every input → just run ninja), so +# prepare_build never runs and neither the anchor nor `ls-remote` would be +# exercised — the assertion would pass identically without the feature. clean +# wipes only `target/`, so mcpp.lock and the git cache both survive. +# +# Run under MCPP_OFFLINE as well: with the commit in the lock and the clone on +# disk there is nothing left to fetch, so this must hold with the network +# refused. It also pins the local-remote rule — this fixture's `git =` is a +# directory path, which costs no network and must never be refused offline. "$MCPP" clean >/dev/null -build2=$("$MCPP" build 2>&1) -echo "$build2" | grep -q 'from lock' || { echo "FAIL: branch dep not resolved from lock on rebuild"; echo "--- build2 ---"; cat <<<"$build2"; exit 1; } +build2=$(MCPP_OFFLINE=1 "$MCPP" build 2>&1) || { + echo "FAIL: offline rebuild with lock + cache present failed" + echo "--- build2 ---"; cat <<<"$build2"; exit 1; } +echo "$build2" | grep -q 'from mcpp.lock' || { echo "FAIL: branch dep not resolved from mcpp.lock on rebuild"; echo "--- build2 ---"; cat <<<"$build2"; exit 1; } echo "$build2" | grep -q 'Cloning' && { echo "FAIL: branch dep re-cloned on rebuild"; exit 1; } || true grep -q 'source = "git+' mcpp.lock || { @@ -186,6 +193,25 @@ git add -A >/dev/null git commit --quiet -m "v2" cd "$TMP/branchapp" + +# mcpp.lock is authoritative, not a hint the cache has to confirm. The branch +# now points at v2 and the git cache is gone, so the only surviving record of +# what this project builds is the commit in mcpp.lock — and the build must +# still produce v1 from it. If the lock were merely an optimisation over the +# cache, evicting ~/.mcpp/git would silently advance the build to v2. +rm -rf "$MCPP_HOME/git" +"$MCPP" clean >/dev/null +"$MCPP" build > branch-evicted.log 2>&1 || { + echo "FAIL: rebuild after git-cache eviction" + cat branch-evicted.log; exit 1; } +triple=$(ls -d target/*/ | head -1) +fp_dir=$(ls "$triple") +out=$(${triple}${fp_dir}/bin/branchapp) +[[ "$out" == *"branch dep v1"* ]] || { + echo "FAIL: git-cache eviction silently advanced the locked branch: $out" + echo "--- branch-evicted.log ---"; cat branch-evicted.log + echo "--- mcpp.lock ---"; cat mcpp.lock; exit 1; } + "$MCPP" update branchlib >/dev/null "$MCPP" clean >/dev/null "$MCPP" build > branch-v2.log 2>&1 diff --git a/tests/unit/test_pm_lock_io.cpp b/tests/unit/test_pm_lock_io.cpp index 74f5a612..709cd8e1 100644 --- a/tests/unit/test_pm_lock_io.cpp +++ b/tests/unit/test_pm_lock_io.cpp @@ -45,6 +45,57 @@ TEST(PmLockIo, ParseGitRev) { EXPECT_FALSE(parsed->resolvedCommit.has_value()); } +// A branch name may legitimately contain '@' — the commit is whatever follows +// the LAST one, matching how prepare.cppm writes `ref + "@" + commit`. +TEST(PmLockIo, ParseGitBranchNameContainingAt) { + auto parsed = mcpp::pm::parse_git_source( + "git+https://github.com/user/repo#branch=feat@v2@584894315b7a4fe4d7957d3c29dc4052b8012860"); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->ref, "feat@v2"); + ASSERT_TRUE(parsed->resolvedCommit.has_value()); + EXPECT_EQ(parsed->resolvedCommit.value(), + "584894315b7a4fe4d7957d3c29dc4052b8012860"); +} + +// `branch=foo@` records no commit; an empty string would make the anchor claim +// a pin it does not have. +TEST(PmLockIo, ParseGitBranchWithEmptyCommit) { + auto parsed = mcpp::pm::parse_git_source( + "git+https://github.com/user/repo#branch=develop@"); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->ref, "develop"); + EXPECT_FALSE(parsed->resolvedCommit.has_value()); +} + +// Only branch entries carry `@`, so a tag or rev whose name contains +// '@' must survive verbatim rather than be split into ref + commit. +TEST(PmLockIo, ParseGitTagNameContainingAtIsNotSplit) { + auto tag = mcpp::pm::parse_git_source( + "git+https://github.com/user/repo#tag=v1.0@rc1"); + ASSERT_TRUE(tag.has_value()); + EXPECT_EQ(tag->ref, "v1.0@rc1"); + EXPECT_FALSE(tag->resolvedCommit.has_value()); + + auto rev = mcpp::pm::parse_git_source( + "git+https://github.com/user/repo#rev=abc123@def"); + ASSERT_TRUE(rev.has_value()); + EXPECT_EQ(rev->ref, "abc123@def"); + EXPECT_FALSE(rev->resolvedCommit.has_value()); +} + +// An scp-like URL puts '@' before the '#', so the URL half must not be +// disturbed by the ref-side split. +TEST(PmLockIo, ParseGitScpLikeUrl) { + auto parsed = mcpp::pm::parse_git_source( + "git+git@github.com:user/repo.git#branch=develop@584894315b7a4fe4d7957d3c29dc4052b8012860"); + ASSERT_TRUE(parsed.has_value()); + EXPECT_EQ(parsed->url, "git@github.com:user/repo.git"); + EXPECT_EQ(parsed->ref, "develop"); + ASSERT_TRUE(parsed->resolvedCommit.has_value()); + EXPECT_EQ(parsed->resolvedCommit.value(), + "584894315b7a4fe4d7957d3c29dc4052b8012860"); +} + TEST(PmLockIo, ParseNonGitSourceReturnsNullopt) { EXPECT_FALSE(mcpp::pm::parse_git_source("index+mcpplibs@1.0.0").has_value()); EXPECT_FALSE(mcpp::pm::parse_git_source("").has_value());