Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ab590ec
feat(slot): 新增版本槽核心逻辑(归一化/槽名/manifest)与测试项目 (T1)
momijijin Aug 17, 2026
ae88e75
feat(settings): GitSync 新增 PinRef 配置读写 (T2)
momijijin Aug 17, 2026
1c3272f
feat(infra): 新增 GitClient/ProcessRunner 可注入薄封装 (T3)
momijijin Aug 17, 2026
47ede1a
test(git): GitClientTests 补 Checkout 行为级断言 (T3 修复R1)
momijijin Aug 17, 2026
629b1b2
feat(pipeline): 版本槽提取流水线(fetch/checkout/build/staging 原子替换/manifest/互…
momijijin Aug 17, 2026
c23289d
fix(pipeline): 分支槽 origin 优先跟随上游 + fire-and-forget 崩溃落痕 (T4 修复R1)
momijijin Aug 17, 2026
95a4e0c
feat(extractor): 反射源改 MetadataLoadContext 加载目标 DLL,主程序包版本与查询口径解耦 (T5)
momijijin Aug 17, 2026
a4d524f
feat(mcp): 查询工具加可选 version 参数(归一化/槽解析)与 GetAvailableVersions/PinVersi…
momijijin Aug 17, 2026
40b6e50
fix(mcp): IsUnsafeSlotName 补盘符形(":")守卫,堵住 ResolveDocsRoot 路径逃逸 (T6 修复R1)
momijijin Aug 17, 2026
4e85b56
feat(api): /api/versions 三端点(pin/extract 鉴权)与 DI 装配 (T7)
momijijin Aug 17, 2026
ccb2c2c
feat(ui): 管理页版本槽管理(PinRef/槽卡片/重提取/删除)与双语文案 (T8)
momijijin Aug 17, 2026
5ee848c
test(pinning): 版本固定性离线集成测试(夹具双版本:不漏/不串版本/不串宿主) (T9)
momijijin Aug 17, 2026
c7daef2
test(pinning): 坑①哨兵确定性武装——PrepareSlots 显式注入宿主 BB 进程内前提 (T9 修复R1)
momijijin Aug 17, 2026
cd479fb
docs(readme): 多版本钉住功能使用说明 (T10)
momijijin Aug 17, 2026
d0f7a8e
fix(mcp): GetComponentList 可空参数修复空参调用兼容并统一槽清单口径
momijijin Aug 17, 2026
7f97065
fix(pinning): pin 校验链前置——空白 400、非法不落盘、拒绝留痕、busy 同步提示
momijijin Aug 17, 2026
f66850f
fix(ui): 版本槽结果双色渲染与启动类提示本地化
momijijin Aug 17, 2026
a7efe10
fix(extractor): 样例 md 写入前创建子目录,修复嵌套 componentKey 导致的提取中断
momijijin Aug 17, 2026
59bc00a
fix(extractor): MLC 上下文随提取段释放,根治跨版本提取的产物文件锁
momijijin Aug 17, 2026
b18110b
feat(mcp): GetComponentDocs 支持 Section 分段与 not-found 跨槽存在性提示
momijijin Aug 17, 2026
2a29a97
feat(infra): GitClient 新增 worktree 创建/移除(LibGit2Sharp 0.31 形态 + 健壮删除链)
momijijin Aug 17, 2026
844d8ef
feat(pipeline): 每槽 worktree 并行提取(即用即删/按槽锁/fetch 全局锁/并发上限 2)
momijijin Aug 17, 2026
8a27136
fix(infra): RemoveWorktree 当场删净同名检出分支,根治同名 worktree 重复创建立败
momijijin Aug 17, 2026
7307734
feat(mcp): GetAvailableVersions 输出行补 extractedAt 时间戳
momijijin Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions BootstrapBlazor.McpServer.Test/AppSettingsPinRefTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using BootstrapBlazor.McpServer.Services;

public class AppSettingsPinRefTests
{
[Fact]
public void ParseJson_ReadsPinRef()
{
var json = """{"GitSync":{"RepositoryUrl":"u","CronSchedule":"c","LocalPath":"l","OutputDir":"o","PinRef":"v10.8.1"}}""";
var m = AppSettingsManager.ParseJson(json);
Assert.Equal("v10.8.1", m.PinRef);
}

[Fact]
public void ParseJson_MissingPinRef_DefaultsEmpty()
{
var json = """{"GitSync":{"RepositoryUrl":"u"}}""";
Assert.Equal("", AppSettingsManager.ParseJson(json).PinRef);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\BootstrapBlazor.McpServer.csproj" />
</ItemGroup>

<ItemGroup>
<!-- T9 离线夹具:源码不编进测试程序集(SDK 默认通配会拾取 Fixtures/**/*.cs),整目录复制到输出供测试现场 dotnet build;
.csproj 受 SDK 默认 DefaultItemExcludes(**/*.*proj)排除,须显式包含才会随构建复制(brief 笔误修正) -->
<Compile Remove="Fixtures/**/*.cs" />
<None Include="Fixtures/**/*.cs" CopyToOutputDirectory="PreserveNewest" />
<None Include="Fixtures/**/*.csproj" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
558 changes: 558 additions & 0 deletions BootstrapBlazor.McpServer.Test/ExtractionServiceTests.cs

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V1/BBFake.V1.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!-- T9 离线夹具:假 BootstrapBlazor 程序集 v1.0.0(仅 ComponentA)。
仅由测试 setup 现场 dotnet build,不入 sln、不作为任何工程的引用。
FrameworkReference 免 NuGet(零包改动红线),离线可编。 -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>BootstrapBlazor</AssemblyName>
<Version>1.0.0</Version>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
10 changes: 10 additions & 0 deletions BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V1/ComponentA.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// T9 夹具 v1.0.0 组件:ComponentBase 派生 + [Parameter] string? Title(命名空间 BootstrapBlazor.Fakes)
using Microsoft.AspNetCore.Components;

namespace BootstrapBlazor.Fakes;

public class ComponentA : ComponentBase
{
[Parameter]
public string? Title { get; set; }
}
14 changes: 14 additions & 0 deletions BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/BBFake.V2.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!-- T9 离线夹具:假 BootstrapBlazor 程序集 v2.0.0(ComponentA 同名重编 + ComponentB)。
仅由测试 setup 现场 dotnet build,不入 sln、不作为任何工程的引用。
FrameworkReference 免 NuGet(零包改动红线),离线可编。 -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>BootstrapBlazor</AssemblyName>
<Version>2.0.0</Version>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
10 changes: 10 additions & 0 deletions BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/ComponentA.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// T9 夹具 v2.0.0 组件:与 v1 同名重编一份(同签名,程序集版本不同——验证提取口径跟随目标 DLL 而非宿主)
using Microsoft.AspNetCore.Components;

namespace BootstrapBlazor.Fakes;

public class ComponentA : ComponentBase
{
[Parameter]
public string? Title { get; set; }
}
10 changes: 10 additions & 0 deletions BootstrapBlazor.McpServer.Test/Fixtures/BBFake.V2/ComponentB.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// T9 夹具 v2.0.0 独有组件:v1 槽绝不应出现(不串版本断言的靶子)
using Microsoft.AspNetCore.Components;

namespace BootstrapBlazor.Fakes;

public class ComponentB : ComponentBase
{
[Parameter]
public string? Title { get; set; }
}
237 changes: 237 additions & 0 deletions BootstrapBlazor.McpServer.Test/GitClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// BootstrapBlazor.McpServer.Test/GitClientTests.cs
using BootstrapBlazor.McpServer.Services;
using LibGit2Sharp;

public class GitClientTests
{
/// <summary>构造本地源仓库:两 commit + tag v9.9.9。非 bare——0.31 在 bare 上 Commit 抛 BareRepositoryException(T1 既有实证)。</summary>
private static string MakeSourceRepo(string root)
{
var src = Path.Combine(root, "src");
var sig = new Signature("t", "t", DateTimeOffset.Now);
Repository.Init(src);
File.WriteAllText(Path.Combine(src, "a.txt"), "hello");
using (var repo = new Repository(src))
{
Commands.Stage(repo, "a.txt");
repo.Commit("init", sig, sig);
File.AppendAllText(Path.Combine(src, "a.txt"), " world");
Commands.Stage(repo, "a.txt");
repo.Commit("second", sig, sig);
repo.ApplyTag("v9.9.9");
}
return src;
}

/// <summary>测试自有脚手架:git 将 loose object 写为只读文件,须先去掉只读属性才能删除目录(best-effort,断言不受影响)。</summary>
private static void CleanupTree(string root)
{
try
{
foreach (var f in Directory.GetFiles(root, "*", SearchOption.AllDirectories))
File.SetAttributes(f, FileAttributes.Normal);
Directory.Delete(root, true);
}
catch { /* best-effort */ }
}

[Fact]
public void Clone_RefExists_Checkout_WorksOffline()
{
var root = Path.Combine(Path.GetTempPath(), "git-t-" + Guid.NewGuid().ToString("N"));
try
{
// LibGit2Sharp 0.31 在 bare 仓库上 Commit 抛 BareRepositoryException(Index 不可用),
// 按 brief 弹性点改为非 bare 源仓库:写文件 + Stage + Commit + 打 tag,断言项不变
var src = Path.Combine(root, "src"); var work = Path.Combine(root, "work");
Repository.Init(src);
File.WriteAllText(Path.Combine(src, "a.txt"), "hello");
using (var repo = new Repository(src))
{
Commands.Stage(repo, "a.txt");
repo.Commit("init", new Signature("t", "t", DateTimeOffset.Now), new Signature("t", "t", DateTimeOffset.Now));
repo.ApplyTag("v10.8.1");
}

var git = new GitClient();
Assert.False(git.IsValid(work));
git.Clone(src, work);
Assert.True(git.IsValid(work));
Assert.True(git.RefExists(work, "v10.8.1"));
Assert.False(git.RefExists(work, "v9.9.9"));
git.Checkout(work, "v10.8.1", force: true);
Assert.Equal(7, git.HeadCommit(work).Length);

// brief 弹性点附带的 Fetch 验证:源仓库追加 v10.8.2 后 Fetch 应拉到新 tag(本地路径 origin,无网络)
string v2Short;
using (var repo = new Repository(src))
{
File.AppendAllText(Path.Combine(src, "a.txt"), " world");
Commands.Stage(repo, "a.txt");
repo.Commit("second", new Signature("t", "t", DateTimeOffset.Now), new Signature("t", "t", DateTimeOffset.Now));
repo.ApplyTag("v10.8.2");
v2Short = repo.Tags["v10.8.2"].Target.Sha[..7]; // lightweight tag 的 Target 即 commit
}
git.Fetch(work);
Assert.True(git.RefExists(work, "v10.8.2"));

// T3 二审 Important 修复:Checkout 行为级断言——克隆后 HEAD 本就指向 v10.8.1 的 commit,
// 仅检 v10.8.1 无法暴露 no-op 实现;此处检出 v10.8.2 并断言 HEAD 真实移动到其 commit(detached + force 路径)
git.Checkout(work, "v10.8.2", force: true);
Assert.Equal(v2Short, git.HeadCommit(work));
}
finally
{
CleanupTree(root);
}
}

[Fact] // T4 修复R1:分支 origin 优先——Fetch 只推进 refs/remotes/origin/*,本地分支停在克隆时 commit;
// 按名读本地分支/只检本地 tip 会导致分支槽恒「up to date」谎报或非默认分支恒「not found」
public void BranchSemantics_OriginFirst_FollowsUpstreamAfterFetch()
{
var root = Path.Combine(Path.GetTempPath(), "git-t-" + Guid.NewGuid().ToString("N"));
try
{
var src = Path.Combine(root, "src"); var work = Path.Combine(root, "work");
var sig = new Signature("t", "t", DateTimeOffset.Now);
Repository.Init(src);
File.WriteAllText(Path.Combine(src, "a.txt"), "hello");
string branch; string c1Short; string devShort;
using (var repo = new Repository(src))
{
Commands.Stage(repo, "a.txt");
c1Short = repo.Commit("init", sig, sig).Sha[..7];
branch = repo.Head.FriendlyName; // 动态取默认分支名,不假设 master/main
repo.CreateBranch("dev"); // 非默认分支:克隆后 work 只会有 origin/dev,无本地 dev
Commands.Checkout(repo, "dev");
File.AppendAllText(Path.Combine(src, "a.txt"), " dev");
Commands.Stage(repo, "a.txt");
devShort = repo.Commit("dev work", sig, sig).Sha[..7];
Commands.Checkout(repo, branch); // 克隆前切回默认分支:clone 只为 HEAD 目标建本地引用,dev 保持仅远端
}

var git = new GitClient();
Repository.Clone(src, work);
using (var w = new Repository(work))
{
Assert.Null(w.Branches["dev"]); // 前置事实:克隆不建本地 dev 引用
}
Assert.True(git.RefExists(work, "dev")); // origin/dev 回退:非默认分支不再恒 not found
Assert.Equal(devShort, git.TipOf(work, "dev")); // TipOf 读到的是 origin/dev 的 tip

// 源仓库默认分支前进(新 commit)→ work Fetch → TipOf/Checkout 应跟随 origin tip 而非停摆的本地 tip
string c3Short;
using (var repo = new Repository(src))
{
File.AppendAllText(Path.Combine(src, "a.txt"), " v3");
Commands.Stage(repo, "a.txt");
c3Short = repo.Commit("advance", sig, sig).Sha[..7];
}
Assert.Equal(c1Short, git.TipOf(work, branch)); // Fetch 前仍是克隆时旧 tip
git.Fetch(work); // 只推进 origin/{branch},不动本地分支
Assert.Equal(c3Short, git.TipOf(work, branch)); // origin 优先:读到上游新 commit
git.Checkout(work, branch, force: true);
Assert.Equal(c3Short, git.HeadCommit(work)); // 检出 origin tip,而非停在克隆时的本地 tip
}
finally
{
CleanupTree(root);
}
}

[Fact] // 并行提取 T1:离线 worktree 创建/移除——检出于 TipOf 解析的 SHA(detached)、注册在册、移除后目录与注册双净
public void Worktree_CreateAtSha_And_Remove_CleansRegistration()
{
var root = Path.Combine(Path.GetTempPath(), "git-t-" + Guid.NewGuid().ToString("N"));
try
{
var src = MakeSourceRepo(root);
var work = Path.Combine(root, "work");
var git = new GitClient();
git.Clone(src, work);
var sha = git.TipOf(work, "v9.9.9"); // 与 Task 2 同源:committish 必须是 TipOf 解析 SHA,不得裸传槽短名
var wtPath = Path.Combine(root, "wt-demo");
git.CreateWorktree(work, "wt-demo", wtPath, sha);
Assert.True(Directory.Exists(wtPath));
// Repository 持有 .git 句柄(Windows 下会挡住后续删除),断言完即确定性释放
using (var w = new Repository(work))
Assert.NotNull(w.Worktrees["wt-demo"]); // 注册在册
using (var wt = new Repository(wtPath))
Assert.Equal(sha, wt.Head.Tip.Sha[..7]); // 检出于指定 SHA(detached)
git.RemoveWorktree(work, "wt-demo");
Assert.False(Directory.Exists(wtPath)); // 目录删净
using (var w = new Repository(work))
{
Assert.Null(w.Worktrees["wt-demo"]); // 注册注销(.git/worktrees 无残留)
Assert.Null(w.Branches["wt-demo"]); // 检出分支删净(Add 以 name 建分支、Prune 不删)
}
}
finally
{
CleanupTree(root);
}
}

[Fact] // 生产事故回归(第二批验收 blocker):同仓库同名 worktree 第二次创建不得撞 "reference already exists"——
// RemoveWorktree 当场删净同名分支后,create→remove→create 再成;清理职责在当前提取收尾,非下次请求
public void Worktree_RecreateSameName_InSameRepo_Succeeds()
{
var root = Path.Combine(Path.GetTempPath(), "git-t-" + Guid.NewGuid().ToString("N"));
try
{
var src = MakeSourceRepo(root);
var work = Path.Combine(root, "work");
var git = new GitClient();
git.Clone(src, work);
var sha = git.TipOf(work, "v9.9.9");
var wtPath = Path.Combine(root, "wt-demo");

git.CreateWorktree(work, "wt-demo", wtPath, sha); // 第一次(等价首次部署提取)
git.RemoveWorktree(work, "wt-demo"); // 第一次收尾清理
git.CreateWorktree(work, "wt-demo", wtPath, sha); // 第二次(等价重提取)——修复前此行抛 reference already exists

Assert.True(Directory.Exists(wtPath));
using (var wt = new Repository(wtPath))
Assert.Equal(sha, wt.Head.Tip.Sha[..7]); // 重建后仍检出于指定 SHA
git.RemoveWorktree(work, "wt-demo");
using (var w = new Repository(work))
{
Assert.Null(w.Worktrees["wt-demo"]);
Assert.Null(w.Branches["wt-demo"]); // 两轮之后仍三净(注册/目录/分支)
}
Assert.False(Directory.Exists(wtPath));
}
finally
{
CleanupTree(root);
}
}

[Fact] // 并行提取 T1:路径残留(含只读文件——模拟上次删除失败的半成品)先健壮清除再重建
public void CreateWorktree_OverwritesStaleDirectory()
{
var root = Path.Combine(Path.GetTempPath(), "git-t-" + Guid.NewGuid().ToString("N"));
try
{
var src = MakeSourceRepo(root);
var work = Path.Combine(root, "work");
var git = new GitClient();
git.Clone(src, work);
var sha = git.TipOf(work, "v9.9.9");
var wtPath = Path.Combine(root, "wt-demo");
Directory.CreateDirectory(wtPath);
var stale = Path.Combine(wtPath, "stale.txt");
File.WriteAllText(stale, "leftover");
File.SetAttributes(stale, FileAttributes.ReadOnly); // git 只读残留同款形态:普通删除即抛 UnauthorizedAccess
git.CreateWorktree(work, "wt-demo", wtPath, sha); // 不抛、重建成功
Assert.False(File.Exists(stale)); // 残留清干净——否则「先清后建」沦为空谈
using (var wt = new Repository(wtPath))
Assert.Equal(sha, wt.Head.Tip.Sha[..7]);
}
finally
{
CleanupTree(root);
}
}
}
Loading