diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4eb5c..1f68c71 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 b8f6d12..a64fd40 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 367cfae..1e641d1 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 685d5c4..3533601 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 2749d08..0ca7bea 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; @@ -709,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. @@ -838,6 +883,32 @@ prepare_build(bool print_fingerprint, mcpp::diag::warning("manifest/schema", w); } + // 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::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 { + // 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"); + } + } + } + // 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 @@ -2983,74 +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 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) + + 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); - 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)); + 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( + 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)); + // 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()) + return std::unexpected(std::format( + "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. std::hash H; - auto urlHash = std::format("{:016x}", + auto gitRoot = mcppHome / "git" / std::format("{:016x}", H(spec.git + "|" + spec.gitRefKind + "|" + spec.gitRev + "|" + resolvedGitRev)); - auto gitRoot = mcppHome / "git" / urlHash; 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) { + // 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 36d74ca..f0ff07c 100644 --- a/src/pm/commands.cppm +++ b/src/pm/commands.cppm @@ -412,14 +412,16 @@ 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. + // 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 3684b7c..a40a50a 100644 --- a/src/pm/lock_io.cppm +++ b/src/pm/lock_io.cppm @@ -33,6 +33,24 @@ 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... +// 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" + 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 +177,45 @@ 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); + 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; + return out; +} + } // namespace mcpp::pm diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 93890ca..987c9c7 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 be24ca2..bfbce6d 100755 --- a/tests/e2e/24_git_dependency.sh +++ b/tests/e2e/24_git_dependency.sh @@ -153,6 +153,26 @@ out=$(${triple}${fp_dir}/bin/branchapp) echo "FAIL: branch dep v1 not invoked: $out" cat branch-v1.log; exit 1; } +# 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_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 || { echo "FAIL: git dep lock source is not marked as git" cat mcpp.lock; exit 1; } @@ -173,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 new file mode 100644 index 0000000..709cd8e --- /dev/null +++ b/tests/unit/test_pm_lock_io.cpp @@ -0,0 +1,107 @@ +#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()); +} + +// 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()); + 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()); +}