dev-manager-tauri/src-tauri/src/onboard/report.rs
lanrtop 97578b2261
All checks were successful
Push & PR Check / check (push) Successful in 1m27s
Push & PR Check / check (pull_request) Successful in 1m6s
feat(onboard/parity): 一键接入 MCP 与 Tauri 投影、Codex 信任计 pending_user 与仓库布局说明(P8e)
- MCP 新增 plan_onboarding(readOnly)与 onboard_project(幂等写操作):schema 各自单独成函数,
  dispatch 一行委托到 mcp_inject 生产入口(中间只做 JSON 类型解析);全局与项目路由都可见,
  项目路由强制本项目、跨项目返回 scope_violation;onboard_project required 含 expected_digest
  与 confirmed;共用可选选项 bypass_permissions / skip_targets;进 STRUCTURED_TOOLS 并给一行摘要
- Tauri 新增同名 async 命令(async fn + spawn_blocking),commands.ts 同步 OnboardOptions、
  OnboardingPlan、OnboardReport 类型与 planOnboarding / onboardProject 封装
- 统筹决定(P8d 审查 ⑩):L1 Codex 列仅因 codex_project_untrusted 未就绪的格不计入 done,
  completion 单列「Codex 信任」(pending_user),next_actions 保留 codex_trust;仓库子目录与
  worktree 补 next_action repo_layout 说明为何到不了 done;非 git 维持不能 done
- 契约同步:project-context.yaml、agent-button-system.md、AGENTS.md 架构速查;P8d 卡记统筹决定落地
- cargo test 413 passed / 0 failed / 6 ignored;pnpm typecheck、pnpm test 通过;三轮 10 处变异全部被杀

Feature-Confirmed: true
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:50:21 +09:00

1492 lines
57 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 一键接入报告(agent-client-parity P8d):分步状态、「完成」判定、L1 矩阵、项目文件改动、HEAD 断言、
//! 建议提交信息与 next_actions。
//!
//! 「完成(done)」只按三端可用子集判定(P8 卡):所有步骤已收敛或按边界不做,且身份、托管块版本、两份 skill、
//! Codex 块、lefthook(L0 静态检查)、Claude local / Codex / DSH 白名单(适配器只读判定)、L1 适用格全部就绪,
//! 项目文件改动可报告(git 仓库)且 HEAD 未变。origin 指向 Gitea、CI workflow、飞轮数据等对新项目必然
//! 不通过的检查进入 next_actions,不算失败(设计 F7)。机器只列出 next_actions,不执行。
//!
//! 统筹决定(2026-10-03,P8d 审查 ⑩):L1 的 Codex 列里仅因 codex_project_untrusted 未就绪的格只等用户在 Codex 里
//! 信任该目录(炼境不写 Codex 信任表,设计 Q5),不计入「L1 适用格」,在 completion 单列为 pending_user「Codex 信任」、
//! next_actions 保留 codex_trust;其余原因的 Codex 格照常计入。非 git 目录仍到不了 done;仓库子目录与 worktree
//! (.git 是文件)的「lefthook hook 挂载」检查只认项目根 .git/hooks,同样到不了 done,由 next_action repo_layout 说明原因。
use super::clients::{ClientId, ClientOutcome};
use super::plan::{BlockedItem, NextAction, OnboardingPlan};
use super::probe_l1::{Cause, Cell, L1Report};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnboardStatus {
/// 三端可用子集全部就绪
Done,
/// 已执行写入,但有步骤未完成或可用子集未全部就绪(不自动回滚)
Partial,
/// 预检未通过(未登记、空路径、相对路径、根目录不存在),零写入
Failed,
/// 未确认、digest 不符或计划有阻断项,零写入
Refused,
/// 同一项目已有一键接入在执行,零写入
Busy,
}
/// 单步状态(设计 §4 第 2 步)。converged / applied / skipped / not_applicable 不阻断依赖它的步骤。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum StepStatus {
/// 已一致,未改动
Converged,
/// 本次写入或刷新
Applied,
/// 按边界不改动(如保留手写配置)
Skipped {
reason: String,
},
/// 本机或本项目不适用(未安装、控制面、WSL、非 git 仓库)
NotApplicable {
reason: String,
},
/// 依赖的步骤未完成,本步未执行
Blocked {
dep: String,
},
Failed {
reason: String,
},
NeedsHuman {
reason: String,
},
}
impl StepStatus {
/// 已处于期望状态或按边界不做:依赖它的步骤照常执行。
pub fn is_settled(&self) -> bool {
matches!(
self,
Self::Converged | Self::Applied | Self::Skipped { .. } | Self::NotApplicable { .. }
)
}
/// 客户端适配器 apply 的结果 → 步骤状态。
pub fn from_client(outcome: &ClientOutcome) -> Self {
match outcome {
ClientOutcome::Converged => Self::Converged,
ClientOutcome::Applied | ClientOutcome::Refreshed | ClientOutcome::Removed => {
Self::Applied
}
ClientOutcome::ManualKept => Self::Skipped {
reason: "manual_kept".into(),
},
ClientOutcome::Deferred => Self::Skipped {
reason: "deferred".into(),
},
ClientOutcome::NotInstalled => Self::NotApplicable {
reason: "not_installed".into(),
},
ClientOutcome::NotApplicable { reason } => Self::NotApplicable {
reason: serde_json::to_value(reason)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_default(),
},
ClientOutcome::OtherProject { .. }
| ClientOutcome::NeedsHuman { .. }
| ClientOutcome::Unavailable { .. } => Self::NeedsHuman {
reason: outcome.describe(),
},
// apply 不应返回 missing(那是只读判定的状态);出现即按未写成处理
ClientOutcome::Missing => Self::Failed {
reason: outcome.describe(),
},
ClientOutcome::Failed { reason } => Self::Failed {
reason: reason.clone(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StepReport {
/// identity、project_files、git_hooks、product_group_buff、token、shim、claude、codex、dsh
/// (本机步骤与计划 machine_changes 同名,便于「预计 → 实际」对照)
pub step: String,
#[serde(flatten)]
pub status: StepStatus,
pub detail: String,
}
impl StepReport {
pub fn new(step: &str, status: StepStatus, detail: impl Into<String>) -> Self {
Self {
step: step.to_string(),
status,
detail: detail.into(),
}
}
}
/// 「完成」判定的一项(来源:l0 静态检查、clients 适配器只读判定、l1、repo)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompletionItem {
pub item: String,
pub source: String,
pub ready: bool,
pub detail: String,
/// 只等用户在客户端里操作、炼境按边界不代做的项(目前只有「Codex 信任」):如实记 ready=false,
/// 但不计入 done 判定;对应的 next_action 保留
#[serde(default)]
pub pending_user: bool,
}
impl CompletionItem {
fn new(item: impl Into<String>, source: &str, ready: bool, detail: impl Into<String>) -> Self {
Self {
item: item.into(),
source: source.into(),
ready,
detail: detail.into(),
pending_user: false,
}
}
}
/// 项目根在 git 里的位置:决定改动能否报告、「lefthook hook 挂载」能否就绪(该检查只认项目根 .git/hooks)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RepoLayout {
/// 不在 git 工作区内:repo_changes 为 unavailable,到不了 done
NotGit,
/// 项目根就是仓库根(.git 为目录)
RepoRoot,
/// 项目根是外层仓库的子目录(repo_root 为外层仓库根,取不到时为空)
Subdirectory { repo_root: String },
/// 项目根的 .git 是文件:linked worktree 或子模块(hooks_dir 为 hook 实际所在目录,取不到时为空)
GitFile { hooks_dir: String },
}
/// 项目文件的待提交改动:炼境只报告、不 add、不 commit。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum RepoChanges {
Available {
/// `git status --porcelain --untracked-files=all -- .` 的逐行输出:只含项目根目录内的改动
/// (项目根是外层仓库的子目录时不列外层仓库的其他改动),路径按 git 惯例相对仓库根
status_porcelain: Vec<String>,
/// `git diff --stat -- .`(只含已跟踪文件的改动;新文件见 status_porcelain 的 ?? 行)
diff_stat: String,
/// 待提交改动里属于接入的文件(相对项目根):本次写入的,加上接入写入序列管理、仍未跟踪的
/// (多为之前的一键接入新建、尚未提交)。提交时只 add 这些——在项目根执行 git add -- <path…>
onboard_files: Vec<PendingFile>,
/// status_porcelain 里不属于接入的改动条数(用户原有的未提交改动等):不要混进接入提交
other_changes: usize,
},
Unavailable {
reason: String,
},
}
/// 一条待提交的接入文件。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingFile {
/// 相对项目根,统一用 /
pub path: String,
/// porcelain 的两位状态码(?? 未跟踪、" M" 已修改等)
pub code: String,
}
/// HEAD 不变断言:一键接入不提交,写入前后 HEAD 相同(仓库尚无提交时两者都是 null)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeadCheck {
pub before: Option<String>,
pub after: Option<String>,
pub unchanged: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardReport {
pub project_id: String,
pub status: OnboardStatus,
/// 一行结论(给人和 agent 读)
pub summary: String,
/// failed / refused / busy 的原因
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// 本次应用的计划 digest;refused 时为重算出的当前 digest
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
/// refused(digest 不符或有阻断项)时附上重算的当前计划,供重新给人确认
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<OnboardingPlan>,
/// 计划的阻断项(refused 因阻断项时非空)
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked: Vec<BlockedItem>,
/// 本次向项目 .claude/settings.json 新写入了 permissions.defaultMode=bypassPermissions(P8 决策 Q4 单列)
pub bypass_permissions: bool,
pub steps: Vec<StepReport>,
pub completion: Vec<CompletionItem>,
/// L1 零模型三端矩阵(每格带证据等级);客户端步骤未完成的一端整列 blocked(dep=<步骤>)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub l1: Option<L1Report>,
/// 最近一次 L3 无头会话结果及是否过期(L3 尚未提供,见 P4d/P4e)
pub l3: String,
/// 本次一键接入实际写入的项目文件(相对项目根,按写入顺序);未执行写入时为空
#[serde(default)]
pub written_files: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_changes: Option<RepoChanges>,
/// 非 git 仓库为 null
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<HeadCheck>,
/// 有待提交的接入文件(repo_changes.onboard_files)时的建议提交信息(Conventional Commits),只针对这些文件;
/// 提交由人或经用户同意的子项目 agent 完成(next_actions 的 commit 一项给出只 add 这些文件的命令)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_commit_message: Option<String>,
pub next_actions: Vec<NextAction>,
pub duration_ms: u64,
}
const L3_NOTE: &str = "L3 无头会话探针尚未提供(P4d/P4e),无最近结果;L1 只到 protocol 证据等级,\
客户端版本变化时应以 L3 复核,桌面 GUI 形态归人工终验";
impl OnboardReport {
/// 未执行任何写入就结束(failed / refused / busy)。
pub fn stopped(
project_id: &str,
status: OnboardStatus,
reason: impl Into<String>,
started: Instant,
) -> Self {
let reason = reason.into();
Self {
project_id: project_id.to_string(),
status,
summary: format!("{}:{reason}", status_label(status)),
reason: Some(reason),
digest: None,
plan: None,
blocked: Vec::new(),
bypass_permissions: false,
steps: Vec::new(),
completion: Vec::new(),
l1: None,
l3: L3_NOTE.into(),
written_files: Vec::new(),
repo_changes: None,
head: None,
suggested_commit_message: None,
next_actions: Vec::new(),
duration_ms: started.elapsed().as_millis() as u64,
}
}
/// 重算的计划与确认的不一致或有阻断项:拒绝,附上当前计划供重新确认。
pub fn refused_with_plan(
project_id: &str,
reason: impl Into<String>,
plan: OnboardingPlan,
started: Instant,
) -> Self {
let mut report = Self::stopped(project_id, OnboardStatus::Refused, reason, started);
report.digest = Some(plan.digest.clone());
report.blocked = plan.blocked.clone();
report.bypass_permissions = plan.bypass_permissions;
report.next_actions = plan.next_actions.clone();
report.plan = Some(plan);
report
}
}
fn status_label(status: OnboardStatus) -> &'static str {
match status {
OnboardStatus::Done => "done",
OnboardStatus::Partial => "partial",
OnboardStatus::Failed => "failed",
OnboardStatus::Refused => "refused",
OnboardStatus::Busy => "busy",
}
}
/// verify_onboarding 中不计入「完成」、未就绪时进入 next_actions 的检查(check 名, action, 说明)。
/// 名称须与 commands::onboarding::verify_onboarding 一致——由 apply 测试在真实检查链上核对,改名即红。
pub(crate) const NEXT_ACTION_CHECKS: [(&str, &str, &str); 5] = [
(
"origin 指向 Gitea",
"gitea_repo",
"在 Gitea 建仓并设为 origin,再配 webhook:工具默认建公开仓库(setup_gitea_repo 的 private 默认 false),\
私有项目建仓时须显式传 private=true",
),
(
"CI workflow 存在",
"ci",
"先调 plan_cicd_workflow 预览,用户确认摘要后再调 apply_cicd_workflow 写入",
),
(
"飞轮 commit 数据流入",
"flywheel",
"首次提交并有 commit 活动后由 buff 轮询采集(或调 poll_now);新项目暂无数据不算接入失败",
),
(
"Node/pnpm 工具链 resolver",
"toolchain",
"开始运行 pnpm 前调 get_toolchain_status(project_id);需要安装时经用户确认后调 prepare_toolchain(confirmed=true)",
),
(
"production delivery profile",
"delivery_profile",
"项目自有声明 .lian-jing/capabilities.yaml 需由项目修复(接入包不覆盖)",
),
];
/// 一次已执行写入的接入,汇总成报告所需的全部事实(apply 收集,报告只做判定与组装)。
pub(crate) struct AppliedFacts {
pub project_id: String,
pub digest: String,
pub bypass_permissions: bool,
pub plan_next_actions: Vec<NextAction>,
pub steps: Vec<StepReport>,
/// verify_onboarding 的(检查名, 是否通过, 说明)
pub l0_checks: Vec<(String, bool, String)>,
/// apply 后各端的只读判定
pub clients_after: Vec<(ClientId, ClientOutcome)>,
pub l1: L1Report,
pub repo_changes: RepoChanges,
pub written_files: Vec<String>,
pub head: Option<HeadCheck>,
pub repo_layout: RepoLayout,
pub started: Instant,
}
pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
let mut completion = Vec::new();
let mut next_actions = facts.plan_next_actions;
for (name, pass, detail) in &facts.l0_checks {
match NEXT_ACTION_CHECKS
.iter()
.find(|(check, _, _)| check == name)
{
Some((_, action, hint)) => {
if !pass {
next_actions.push(NextAction {
action: (*action).into(),
detail: format!("{hint}(当前:{detail})"),
});
}
}
None => completion.push(CompletionItem::new(
name.clone(),
"l0",
*pass,
detail.clone(),
)),
}
}
for (client, outcome) in &facts.clients_after {
completion.push(CompletionItem::new(
client_label(*client),
"clients",
outcome.is_settled(),
outcome.describe(),
));
}
let (l1_ready, awaiting_trust) = l1_readiness(&facts.l1);
completion.push(CompletionItem::new(
"L1 适用格",
"l1",
l1_ready,
l1_summary(&facts.l1, awaiting_trust),
));
if awaiting_trust > 0 {
completion.push(CompletionItem {
pending_user: true,
..CompletionItem::new(
"Codex 信任",
"l1",
false,
format!(
"Codex 列 {awaiting_trust} 格仅因目录未受 Codex 信任而未就绪(codex_project_untrusted):Codex 只加载受信任目录的\
项目 .codex/config.toml,信任由你在 Codex 里操作、炼境不写 Codex 信任表;不计入 done,信任后重跑一键接入\
或 probe_client_parity 即就绪"
),
)
});
if !next_actions
.iter()
.any(|action| action.action == "codex_trust")
{
next_actions.push(NextAction {
action: "codex_trust".into(),
detail: "L1 显示 Codex 在该目录看不到项目配置(目录未受信任):请在 Codex 里信任该目录(炼境不改 Codex 信任表),\
之后新开的 Codex 会话才加载项目 .codex/config.toml"
.into(),
});
}
}
completion.push(match &facts.repo_changes {
RepoChanges::Available {
onboard_files,
other_changes,
..
} => CompletionItem::new(
"项目文件改动可报告",
"repo",
true,
format!(
"{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}",
onboard_files.len(),
if *other_changes > 0 {
format!(";另有 {other_changes} 处改动不属于接入,提交时勿一并 add")
} else {
String::new()
}
),
),
RepoChanges::Unavailable { reason } => {
CompletionItem::new("项目文件改动可报告", "repo", false, reason.clone())
}
});
if let Some(head) = &facts.head {
completion.push(CompletionItem::new(
"HEAD 未变",
"repo",
head.unchanged,
if head.unchanged {
"写入前后 HEAD 相同(炼境未提交)"
} else {
"写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属"
},
));
}
let step = |name: &str| facts.steps.iter().find(|s| s.step == name);
let applied = |name: &str| step(name).is_some_and(|s| s.status == StepStatus::Applied);
if let Some(hooks) = step("git_hooks") {
if let StepStatus::NeedsHuman { reason } = &hooks.status {
next_actions.push(NextAction {
action: "lefthook".into(),
detail: format!("安装 lefthook 后重跑一键接入:{reason}"),
});
}
}
let layout_action = repo_layout_next_action(&facts.repo_layout);
let layout_blocks_done = layout_action.is_some();
next_actions.extend(layout_action);
if applied("dsh") {
next_actions.push(NextAction {
action: "restart_dsh_gui".into(),
detail: "DSH 托管块已变化:重启 DSH 桌面 GUI 后新开的会话才按工作区连项目地址".into(),
});
}
if applied("claude") {
next_actions.push(NextAction {
action: "claude_trust".into(),
detail: "Claude Code 交互模式首次打开该目录时需确认 trust(-p 无头模式不受影响)"
.into(),
});
}
if ["identity", "project_files", "claude", "codex", "dsh"]
.iter()
.any(|name| applied(name))
{
// P4a 审查 ③:L1 的客户端自省不代表已打开的会话已重载,不据 reload_required 推断
next_actions.push(NextAction {
action: "reopen_sessions".into(),
detail: "本次改了项目配置或客户端注册:已打开的 Claude Code / Codex / DSH 会话不会自动加载,需重开;\
L1 是新起的检查进程,不代表已打开的会话已重载"
.into(),
});
}
let commit_message = suggested_commit_message(&facts.repo_changes);
if let (
Some(message),
RepoChanges::Available {
onboard_files,
other_changes,
..
},
) = (&commit_message, &facts.repo_changes)
{
let paths: Vec<String> = onboard_files
.iter()
.map(|file| shell_quote(&file.path))
.collect();
next_actions.push(NextAction {
action: "commit".into(),
detail: format!(
"只提交接入文件:在项目根执行 git add -- {},再以「{message}」提交(由人或经用户同意的子项目 agent \
按其 git 工作流完成,炼境不 add、不 commit){}",
paths.join(" "),
if *other_changes > 0 {
format!(";另有 {other_changes} 处改动不属于接入(见 repo_changes),勿用 git add -A 一并提交")
} else {
String::new()
}
),
});
}
let unsettled: Vec<String> = facts
.steps
.iter()
.filter(|s| !s.status.is_settled())
.map(|s| s.step.clone())
.collect();
// pending_user 的项(Codex 信任)如实列为未就绪,但只等用户在客户端里操作,不计入 done
let not_ready: Vec<String> = completion
.iter()
.filter(|item| !item.ready && !item.pending_user)
.map(|item| item.item.clone())
.collect();
let status = if unsettled.is_empty() && not_ready.is_empty() {
OnboardStatus::Done
} else {
OnboardStatus::Partial
};
let changed = commit_message.is_some();
let pending_note = if awaiting_trust > 0 {
format!(
";Codex 列 {awaiting_trust} 格待你在 Codex 里信任该目录后就绪(completion 的 pending_user,不计入 done)"
)
} else {
String::new()
};
let summary = match status {
OnboardStatus::Done => format!(
"done:{} 步均已收敛,三端可用子集全部就绪{pending_note};{}待人处理 {} 项(next_actions)",
facts.steps.len(),
if changed {
"接入文件有待提交改动(炼境不提交,只 add onboard_files);"
} else {
""
},
next_actions.len()
),
_ => format!(
"partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{pending_note}{}",
unsettled.join("、"),
not_ready.join("、"),
if layout_blocks_done {
";项目根不是独立的仓库根(仓库子目录或 worktree),按当前检查口径到不了 done,原因见 next_actions 的 repo_layout"
} else {
""
}
),
};
OnboardReport {
project_id: facts.project_id,
status,
summary,
reason: None,
digest: Some(facts.digest),
plan: None,
blocked: Vec::new(),
bypass_permissions: facts.bypass_permissions,
steps: facts.steps,
completion,
l1: Some(facts.l1),
l3: L3_NOTE.into(),
written_files: facts.written_files,
suggested_commit_message: commit_message,
repo_changes: Some(facts.repo_changes),
head: facts.head,
next_actions,
duration_ms: facts.started.elapsed().as_millis() as u64,
}
}
fn client_label(client: ClientId) -> &'static str {
match client {
ClientId::Claude => "Claude local 条目",
ClientId::Codex => "Codex 托管块",
ClientId::Dsh => "DSH 白名单",
}
}
fn l1_summary(report: &L1Report, awaiting_trust: usize) -> String {
let cells: Vec<&Cell> = report
.clients
.iter()
.flat_map(|column| column.cells.values())
.collect();
let bad = cells.iter().filter(|cell| !cell.is_acceptable()).count();
format!(
"{} 端 {} 格,{} 格未就绪(含 blocked),耗时 {} ms{}",
report.clients.len(),
cells.len(),
bad,
report.duration_ms,
if awaiting_trust > 0 {
format!(
";其中 {awaiting_trust} 格仅因 Codex 未信任该目录,单列「Codex 信任」、不计入本项"
)
} else {
String::new()
}
)
}
/// L1 的「完成」口径(统筹决定,P8d 审查 ⑩):返回(计入 done 的格是否全部就绪, 只等 Codex 信任的格数)。
/// 只等 Codex 信任 = Codex 列里归因为 codex_project_untrusted 的未就绪格;其余未就绪格(含 Codex 列的其他归因)照常计入。
/// L1Report.all_applicable_ready 保持 L1 自身的原始判定,不在这里改写。
fn l1_readiness(report: &L1Report) -> (bool, usize) {
let mut ready = true;
let mut awaiting_trust = 0;
for column in &report.clients {
for cell in column.cells.values().filter(|cell| !cell.is_acceptable()) {
if column.client == ClientId::Codex && cell.cause == Some(Cause::CodexProjectUntrusted)
{
awaiting_trust += 1;
} else {
ready = false;
}
}
}
(ready, awaiting_trust)
}
/// 项目根不是独立仓库根时说明为何到不了 done(next_action repo_layout);仓库根与非 git 目录不给
/// (非 git 由「项目文件改动可报告」未就绪说明)。
fn repo_layout_next_action(layout: &RepoLayout) -> Option<NextAction> {
const WHY: &str = "verify_onboarding 的「lefthook hook 挂载」只认项目根 .git/hooks/pre-commit,这一项不会就绪,\
所以本项目按当前检查口径到不了 done(三端可用不受影响);「origin 指向 Gitea」同样只读项目根 .git/config,\
gitea_repo 一项请按所在仓库的实际 origin 判断";
let unknown = |text: &str| {
if text.is_empty() {
"(未能取得)".to_string()
} else {
text.to_string()
}
};
let detail = match layout {
RepoLayout::NotGit | RepoLayout::RepoRoot => return None,
RepoLayout::Subdirectory { repo_root } => format!(
"项目根是 git 仓库 {} 的子目录:hook 归外层仓库管理,炼境不改写外层仓库的 hooks(git_hooks 记 not_applicable);\
{WHY}。要到 done:在仓库根登记并接入,或让该目录自成仓库",
unknown(repo_root)
),
RepoLayout::GitFile { hooks_dir } => format!(
"项目根的 .git 是文件(git worktree 或子模块),hook 实际在 {};{WHY},且 git_hooks 步每次都会重跑 lefthook install。\
要到 done:worktree 请在主仓库根登记并接入(worktree 与主仓库共用 hooks)",
unknown(hooks_dir)
),
};
Some(NextAction {
action: "repo_layout".into(),
detail,
})
}
/// 有待提交的接入文件时给一条 Conventional Commits 提交信息(只针对 onboard_files,用户原有改动不计):
/// 有新文件记「接入」,只有修改记「收敛」。
fn suggested_commit_message(changes: &RepoChanges) -> Option<String> {
let RepoChanges::Available { onboard_files, .. } = changes else {
return None;
};
if onboard_files.is_empty() {
return None;
}
Some(if onboard_files.iter().any(|file| file.code == "??") {
"chore(lian-jing): 一键接入炼境(项目身份、接入包规则与 skill 双投影、Codex 托管块)".into()
} else {
"chore(lian-jing): 收敛炼境接入包与项目身份到当前版本".into()
})
}
/// git add 命令行里的路径:含空白或引号时加双引号(接入文件路径本身不含这些字符,只为稳妥)。
fn shell_quote(path: &str) -> String {
if path
.chars()
.any(|c| c.is_whitespace() || c == '"' || c == '\'')
{
format!("\"{}\"", path.replace('"', "\\\""))
} else {
path.to_string()
}
}
/// 一键接入结束后补写的 mcp_inject 事件(level, message):get_diagnostics「MCP 注入状态」与设置页近 7 天注入告警
/// 只认 category=mcp_inject(每个项目取最后一条),一键接入做了等同注入的工作,不补写会让旧的注入失败记录一直
/// 显示、新项目没有注入记录(P8d 审查 ④)。未执行写入(failed / refused / busy)不写;token、shim 与三端步骤
/// 都已完成记 info,否则记 warn 并列出未完成的步骤。
pub fn inject_event(report: &OnboardReport) -> Option<(&'static str, String)> {
const INJECT_STEPS: [&str; 5] = ["token", "shim", "claude", "codex", "dsh"];
if report.steps.is_empty() {
return None;
}
let unsettled: Vec<String> = report
.steps
.iter()
.filter(|step| INJECT_STEPS.contains(&step.step.as_str()) && !step.status.is_settled())
.map(|step| format!("{}({})", step.step, step.detail))
.collect();
Some(if unsettled.is_empty() {
(
"info",
"MCP 配置注入成功(一键接入:token、稳定入口与三端客户端步骤均已完成)".into(),
)
} else {
(
"warn",
format!("一键接入注入未完成: {}", unsettled.join(";")),
)
})
}
// ── git 只读观测(--no-optional-locks:不抢 index.lock,避免与子项目里的 agent 提交冲突)──
fn git_stdout(root: &Path, args: &[&str]) -> Result<String, String> {
let output = crate::silent_cmd("git")
.arg("--no-optional-locks")
.args(args)
.current_dir(root)
.output()
.map_err(|error| format!("无法执行 git: {error}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
/// 根目录在 git 工作区内。
pub(crate) fn is_git_repo(root: &Path) -> bool {
git_stdout(root, &["rev-parse", "--is-inside-work-tree"]).is_ok_and(|out| out.trim() == "true")
}
/// 项目根在 git 里的位置(只读):.git 为目录是仓库根,为文件是 worktree 或子模块,不存在而在工作区内是子目录。
pub(crate) fn repo_layout(root: &Path) -> RepoLayout {
if !is_git_repo(root) {
return RepoLayout::NotGit;
}
let trimmed = |result: Result<String, String>| {
result.map(|out| out.trim().to_string()).unwrap_or_default()
};
let dot_git = root.join(".git");
if dot_git.is_dir() {
RepoLayout::RepoRoot
} else if dot_git.is_file() {
RepoLayout::GitFile {
hooks_dir: trimmed(git_stdout(
root,
&["rev-parse", "--path-format=absolute", "--git-path", "hooks"],
)),
}
} else {
RepoLayout::Subdirectory {
repo_root: trimmed(git_stdout(root, &["rev-parse", "--show-toplevel"])),
}
}
}
/// 当前 HEAD 提交(仓库尚无提交、或不是 git 仓库时为 None)。
pub(crate) fn git_head(root: &Path) -> Option<String> {
git_stdout(root, &["rev-parse", "--verify", "-q", "HEAD"])
.ok()
.map(|out| out.trim().to_string())
.filter(|head| !head.is_empty())
}
pub(crate) fn head_check(root: &Path, before: Option<String>) -> Option<HeadCheck> {
if !is_git_repo(root) {
return None;
}
let after = git_head(root);
Some(HeadCheck {
unchanged: before == after,
before,
after,
})
}
/// 项目根目录内的待提交改动(pathspec「-- .」限定在项目根,外层仓库的其他改动不列),并按接入归属分类:
/// written 为本次写入的文件,managed 为接入写入序列判过存在、读过或写过的文件(均相对项目根)。
/// 接入文件 = 本次写入的 + 写入序列管理且仍未跟踪的(多为之前的一键接入新建、尚未提交);
/// 写入序列管理但本次没写、又是已跟踪文件的改动,来源无法区分,归入其他改动(宁可少列,不把用户改动混进接入提交)。
pub(crate) fn repo_changes(
root: &Path,
written: &[String],
managed: &std::collections::BTreeSet<String>,
) -> RepoChanges {
if !is_git_repo(root) {
return RepoChanges::Unavailable {
reason: "不是 git 仓库:无法用 git status 报告待提交改动".into(),
};
}
let observed = (|| -> Result<(String, String, String, String), String> {
Ok((
git_stdout(
root,
&["status", "--porcelain", "--untracked-files=all", "--", "."],
)?,
git_stdout(root, &["diff", "--stat", "--", "."])?,
git_stdout(root, &["rev-parse", "--show-prefix"])?,
git_stdout(
root,
&[
"status",
"--porcelain",
"-z",
"--untracked-files=all",
"--",
".",
],
)?,
))
})();
let (status, diff, prefix, entries) = match observed {
Ok(observed) => observed,
Err(error) => {
return RepoChanges::Unavailable {
reason: format!("git 读取改动失败: {error}"),
}
}
};
let prefix = prefix.trim();
let mut onboard_files = Vec::new();
let mut other_changes = 0;
for (code, path) in parse_porcelain_z(&entries) {
let relative = path.strip_prefix(prefix).unwrap_or(&path).to_string();
let onboard = written.contains(&relative) || (code == "??" && managed.contains(&relative));
if onboard {
onboard_files.push(PendingFile {
path: relative,
code,
});
} else {
other_changes += 1;
}
}
RepoChanges::Available {
status_porcelain: status
.lines()
.filter(|line| !line.trim().is_empty())
.map(str::to_string)
.collect(),
diff_stat: diff.trim_end().to_string(),
onboard_files,
other_changes,
}
}
/// `git status --porcelain -z` → (两位状态码, 相对仓库根的路径)。改名与复制条目后面跟一个原路径,跳过。
fn parse_porcelain_z(output: &str) -> Vec<(String, String)> {
let mut entries = Vec::new();
let mut tokens = output.split('\0').filter(|token| !token.is_empty());
while let Some(token) = tokens.next() {
let (Some(code), Some(path)) = (token.get(..2), token.get(3..)) else {
continue;
};
if code.contains(['R', 'C']) {
tokens.next();
}
entries.push((code.to_string(), path.to_string()));
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
use crate::onboard::clients::NotApplicableReason;
#[test]
fn client_outcomes_map_to_step_statuses_and_settledness() {
let cases = [
(ClientOutcome::Converged, "converged", true),
(ClientOutcome::Applied, "applied", true),
(ClientOutcome::Refreshed, "applied", true),
(ClientOutcome::ManualKept, "skipped", true),
(ClientOutcome::NotInstalled, "not_applicable", true),
(
ClientOutcome::NotApplicable {
reason: NotApplicableReason::ControlPlane,
},
"not_applicable",
true,
),
(
ClientOutcome::OtherProject { owner: "x".into() },
"needs_human",
false,
),
(
ClientOutcome::Unavailable {
reason: "claude CLI 未找到".into(),
},
"needs_human",
false,
),
(ClientOutcome::Missing, "failed", false),
(
ClientOutcome::Failed {
reason: "boom".into(),
},
"failed",
false,
),
];
for (outcome, status, settled) in cases {
let mapped = StepStatus::from_client(&outcome);
let json =
serde_json::to_value(StepReport::new("claude", mapped.clone(), "d")).unwrap();
assert_eq!(json["status"], status, "{outcome:?}");
assert_eq!(mapped.is_settled(), settled, "{outcome:?}");
}
assert_eq!(
StepStatus::from_client(&ClientOutcome::NotApplicable {
reason: NotApplicableReason::Wsl
}),
StepStatus::NotApplicable {
reason: "wsl".into()
}
);
}
#[test]
fn step_report_serializes_flat() {
let report = StepReport::new(
"codex",
StepStatus::Blocked {
dep: "token".into(),
},
"依赖的 token 步骤未完成",
);
assert_eq!(
serde_json::to_value(&report).unwrap(),
serde_json::json!({
"step": "codex",
"status": "blocked",
"dep": "token",
"detail": "依赖的 token 步骤未完成"
})
);
assert_eq!(serde_json::to_value(OnboardStatus::Busy).unwrap(), "busy");
}
#[test]
fn head_check_flags_a_moved_head_and_skips_non_git() {
let root = std::env::temp_dir().join(format!("lj_p8d_head_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
assert!(
head_check(&root, None).is_none(),
"非 git 仓库不做 HEAD 断言"
);
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
};
git(&["init", "-q"]);
let unborn = head_check(&root, git_head(&root)).unwrap();
assert!(unborn.unchanged && unborn.before.is_none());
std::fs::write(root.join("a.txt"), "a\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "--no-verify", "-m", "a"]);
let moved = head_check(&root, None).unwrap();
assert!(!moved.unchanged, "写入期间出现提交须如实报告");
let none = std::collections::BTreeSet::new();
assert_eq!(
suggested_commit_message(&repo_changes(&root, &[], &none)),
None,
"无改动不给提交信息"
);
std::fs::write(root.join("a.txt"), "b\n").unwrap();
// 不是接入写的改动:计入其他改动,不给接入提交信息(P8d 审查 ③)
let foreign = repo_changes(&root, &[], &none);
assert!(
matches!(&foreign, RepoChanges::Available { onboard_files, other_changes: 1, .. } if onboard_files.is_empty()),
"{foreign:?}"
);
assert_eq!(suggested_commit_message(&foreign), None);
// 本次写入的已跟踪文件 → 收敛
assert!(
suggested_commit_message(&repo_changes(&root, &["a.txt".to_string()], &none))
.is_some_and(|message| message.starts_with("chore(lian-jing): 收敛"))
);
// 写入序列管理、本次没写的已跟踪文件改动:来源无法区分,不算接入文件
let managed: std::collections::BTreeSet<String> = ["a.txt".to_string()].into();
assert!(matches!(
repo_changes(&root, &[], &managed),
RepoChanges::Available {
other_changes: 1,
..
}
));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn porcelain_z_parsing_skips_rename_sources() {
assert_eq!(
parse_porcelain_z("?? a b.md\0R new.md\0old.md\0 M sub/c.md\0"),
[
("??".to_string(), "a b.md".to_string()),
("R ".to_string(), "new.md".to_string()),
(" M".to_string(), "sub/c.md".to_string()),
]
);
assert_eq!(shell_quote("AGENTS.md"), "AGENTS.md");
assert_eq!(shell_quote("a b.md"), "\"a b.md\"");
}
/// 一键接入补写 mcp_inject 事件(P8d 审查 ④):未写入不记;注入相关步骤全部完成记 info,否则 warn 列出未完成的。
#[test]
fn inject_event_follows_injection_steps_only() {
let started = Instant::now();
let stopped = OnboardReport::stopped("p", OnboardStatus::Refused, "未确认", started);
assert_eq!(inject_event(&stopped), None, "未执行写入不补写注入事件");
let mut report = stopped.clone();
report.status = OnboardStatus::Partial;
report.steps = vec![
StepReport::new(
"git_hooks",
StepStatus::NeedsHuman {
reason: "缺 lefthook".into(),
},
"缺 lefthook",
),
StepReport::new("token", StepStatus::Applied, "已生成"),
StepReport::new("shim", StepStatus::Converged, "最新"),
StepReport::new(
"claude",
StepStatus::Skipped {
reason: "manual_kept".into(),
},
"跳过",
),
StepReport::new("codex", StepStatus::Applied, "已写入"),
StepReport::new(
"dsh",
StepStatus::NotApplicable {
reason: "not_installed".into(),
},
"未安装",
),
];
let (level, message) = inject_event(&report).unwrap();
assert_eq!(level, "info", "与注入无关的 git_hooks 未完成不影响注入状态");
assert!(message.contains("注入成功"), "{message}");
report.steps.push(StepReport::new(
"claude",
StepStatus::Failed {
reason: "CLI 失败".into(),
},
"CLI 失败",
));
let (level, message) = inject_event(&report).unwrap();
assert_eq!(level, "warn");
assert!(message.contains("claude"), "{message}");
}
// ── 统筹决定(P8d 审查 ⑩):Codex 信任单列 pending_user、仓库布局说明 ──────────────────────
use crate::onboard::probe_l1::{CellState, Check, ClientColumn, EvidenceLevel, ProbeProfile};
const CHECKS: [Check; 6] = [Check::A, Check::B, Check::C, Check::D, Check::E, Check::F];
fn cell(state: CellState, cause: Option<Cause>) -> Cell {
Cell {
state,
cause,
evidence: EvidenceLevel::Protocol,
detail: String::new(),
dep: None,
}
}
/// 一端的 L1 列:未列出的检查项为 ready。
fn column(client: ClientId, overrides: &[(Check, CellState, Cause)]) -> ClientColumn {
ClientColumn {
client,
version: None,
route: None,
cells: CHECKS
.iter()
.map(|check| {
let value = overrides
.iter()
.find(|(name, _, _)| name == check)
.map_or(cell(CellState::Ready, None), |(_, state, cause)| {
cell(*state, Some(*cause))
});
(*check, value)
})
.collect(),
duration_ms: 1,
}
}
fn l1(columns: Vec<ClientColumn>) -> L1Report {
let all_applicable_ready = columns
.iter()
.all(|column| column.cells.values().all(Cell::is_acceptable));
L1Report {
project_id: "p".into(),
tier: "l1".into(),
probe_spec_version: 1,
profile: ProbeProfile::Project,
expected_route: "http://127.0.0.1:27190/p/p/mcp".into(),
expected_tools: 1,
nonce: "n".into(),
clients: columns,
all_applicable_ready,
duration_ms: 1,
note: String::new(),
}
}
const UNTRUSTED: [(Check, CellState, Cause); 4] = [
(Check::C, CellState::Fail, Cause::CodexProjectUntrusted),
(Check::D, CellState::Fail, Cause::CodexProjectUntrusted),
(Check::E, CellState::Fail, Cause::CodexProjectUntrusted),
(Check::F, CellState::Fail, Cause::CodexProjectUntrusted),
];
/// 九步全部收敛、L0 与只读判定全部就绪的已执行写入;L1、仓库布局、额外 L0 检查与计划的 next_actions 由用例给出。
fn facts(
l1: L1Report,
repo_layout: RepoLayout,
extra_l0: &[(&str, bool)],
plan_next_actions: Vec<NextAction>,
) -> AppliedFacts {
let mut l0_checks = vec![
("AGENTS.md 管理块版本".to_string(), true, "ok".to_string()),
(
"origin 指向 Gitea".to_string(),
false,
"无 origin".to_string(),
),
];
l0_checks.extend(
extra_l0
.iter()
.map(|(name, pass)| (name.to_string(), *pass, String::new())),
);
AppliedFacts {
project_id: "p".into(),
digest: "d".into(),
bypass_permissions: false,
plan_next_actions,
steps: [
"identity",
"project_files",
"git_hooks",
"product_group_buff",
"token",
"shim",
"claude",
"codex",
"dsh",
]
.iter()
.map(|name| StepReport::new(name, StepStatus::Converged, "一致"))
.collect(),
l0_checks,
clients_after: [ClientId::Claude, ClientId::Codex, ClientId::Dsh]
.into_iter()
.map(|client| (client, ClientOutcome::Converged))
.collect(),
l1,
repo_changes: RepoChanges::Available {
status_porcelain: Vec::new(),
diff_stat: String::new(),
onboard_files: Vec::new(),
other_changes: 0,
},
written_files: Vec::new(),
head: Some(HeadCheck {
before: Some("h".into()),
after: Some("h".into()),
unchanged: true,
}),
repo_layout,
started: Instant::now(),
}
}
fn ready_matrix(codex: &[(Check, CellState, Cause)]) -> L1Report {
l1(vec![
column(ClientId::Claude, &[]),
column(ClientId::Codex, codex),
column(ClientId::Dsh, &[]),
])
}
fn count_actions(report: &OnboardReport, name: &str) -> usize {
report
.next_actions
.iter()
.filter(|action| action.action == name)
.count()
}
#[test]
fn codex_trust_only_cells_are_pending_user_and_do_not_block_done() {
let report = applied_report(facts(
ready_matrix(&UNTRUSTED),
RepoLayout::RepoRoot,
&[],
Vec::new(),
));
assert_eq!(report.status, OnboardStatus::Done, "{report:#?}");
assert!(
!report.l1.as_ref().unwrap().all_applicable_ready,
"L1 原始判定保留"
);
let l1_item = report
.completion
.iter()
.find(|item| item.item == "L1 适用格")
.unwrap();
assert!(l1_item.ready && !l1_item.pending_user);
assert!(
l1_item.detail.contains("4 格仅因 Codex 未信任"),
"{}",
l1_item.detail
);
let pending: Vec<&CompletionItem> = report
.completion
.iter()
.filter(|item| item.pending_user)
.collect();
assert_eq!(pending.len(), 1);
assert_eq!(
(
pending[0].item.as_str(),
pending[0].source.as_str(),
pending[0].ready
),
("Codex 信任", "l1", false)
);
assert_eq!(count_actions(&report, "codex_trust"), 1, "L1 的结论补一条");
assert!(report.summary.starts_with("done:"), "{}", report.summary);
assert!(
report.summary.contains("pending_user"),
"{}",
report.summary
);
let json = serde_json::to_value(&pending[0]).unwrap();
assert_eq!(json["pending_user"], true, "pending_user 进入契约");
// 计划已列 codex_trust:不重复
let planned = applied_report(facts(
ready_matrix(&UNTRUSTED),
RepoLayout::RepoRoot,
&[],
vec![NextAction {
action: "codex_trust".into(),
detail: "计划预判".into(),
}],
));
assert_eq!(count_actions(&planned, "codex_trust"), 1);
assert_eq!(planned.next_actions[0].detail, "计划预判");
// 全部就绪:没有 pending_user 项,也不补 codex_trust
let clean = applied_report(facts(
ready_matrix(&[]),
RepoLayout::RepoRoot,
&[],
Vec::new(),
));
assert_eq!(clean.status, OnboardStatus::Done);
assert!(clean.completion.iter().all(|item| !item.pending_user));
assert_eq!(count_actions(&clean, "codex_trust"), 0);
assert!(!clean.summary.contains("pending_user"));
}
#[test]
fn other_not_ready_cells_still_block_done() {
// Codex 列混入别的归因:只单列 codex_project_untrusted 的格,其余照常计入
let mut mixed = UNTRUSTED.to_vec();
mixed[3] = (Check::F, CellState::Fail, Cause::AuthFailed);
let report = applied_report(facts(
ready_matrix(&mixed),
RepoLayout::RepoRoot,
&[],
Vec::new(),
));
assert_eq!(report.status, OnboardStatus::Partial, "{report:#?}");
assert!(report
.completion
.iter()
.any(|item| item.item == "L1 适用格" && !item.ready));
assert!(report
.completion
.iter()
.any(|item| item.pending_user && item.detail.contains("3 格")));
// 只认 Codex 列:别的列出现同一归因也照常计入
let other_column = l1(vec![
column(
ClientId::Claude,
&[(Check::C, CellState::Fail, Cause::CodexProjectUntrusted)],
),
column(ClientId::Codex, &[]),
column(ClientId::Dsh, &[]),
]);
let report = applied_report(facts(other_column, RepoLayout::RepoRoot, &[], Vec::new()));
assert_eq!(report.status, OnboardStatus::Partial);
assert!(report.completion.iter().all(|item| !item.pending_user));
// blocked(依赖步骤未完成)的 Codex 格不是「只等信任」
let blocked = ready_matrix(&[(Check::C, CellState::Blocked, Cause::Dependency)]);
let report = applied_report(facts(blocked, RepoLayout::RepoRoot, &[], Vec::new()));
assert_eq!(report.status, OnboardStatus::Partial);
// pending_user 只豁免 L1 的 Codex 信任:别的未就绪检查照常让 status=partial
let report = applied_report(facts(
ready_matrix(&UNTRUSTED),
RepoLayout::RepoRoot,
&[("Claude 炼境技能已分发", false)],
Vec::new(),
));
assert_eq!(report.status, OnboardStatus::Partial);
assert!(
report.summary.contains("Claude 炼境技能已分发"),
"{}",
report.summary
);
assert!(
report.summary.contains("pending_user"),
"{}",
report.summary
);
}
#[test]
fn repo_layout_next_action_explains_why_done_is_unreachable() {
let hooks_missing = [("lefthook hook 挂载", false)];
let subdir = applied_report(facts(
ready_matrix(&[]),
RepoLayout::Subdirectory {
repo_root: "E:/work/outer".into(),
},
&hooks_missing,
Vec::new(),
));
assert_eq!(subdir.status, OnboardStatus::Partial);
let action = subdir
.next_actions
.iter()
.find(|action| action.action == "repo_layout")
.expect("仓库子目录给出 repo_layout");
for phrase in ["E:/work/outer", "子目录", "lefthook hook 挂载", "done"] {
assert!(
action.detail.contains(phrase),
"缺「{phrase}」:{}",
action.detail
);
}
assert!(subdir.summary.contains("repo_layout"), "{}", subdir.summary);
let worktree = applied_report(facts(
ready_matrix(&[]),
RepoLayout::GitFile {
hooks_dir: "E:/work/main/.git/hooks".into(),
},
&hooks_missing,
Vec::new(),
));
let action = worktree
.next_actions
.iter()
.find(|action| action.action == "repo_layout")
.expect("worktree 给出 repo_layout");
for phrase in ["E:/work/main/.git/hooks", "worktree", "lefthook hook 挂载"] {
assert!(
action.detail.contains(phrase),
"缺「{phrase}」:{}",
action.detail
);
}
// 仓库根与非 git 目录不给(非 git 由「项目文件改动可报告」未就绪说明)
for layout in [RepoLayout::RepoRoot, RepoLayout::NotGit] {
let report = applied_report(facts(ready_matrix(&[]), layout, &[], Vec::new()));
assert_eq!(count_actions(&report, "repo_layout"), 0);
assert!(!report.summary.contains("repo_layout"));
}
}
#[test]
fn repo_layout_detects_root_subdirectory_worktree_and_non_git() {
let base = std::env::temp_dir().join(format!("lj_p8e_layout_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let main = base.join("main");
let plain = base.join("plain");
std::fs::create_dir_all(main.join("sub")).unwrap();
std::fs::create_dir_all(&plain).unwrap();
let git = |dir: &Path, args: &[&str]| {
let out = std::process::Command::new("git")
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
};
git(&main, &["init", "-q"]);
std::fs::write(main.join("a.txt"), "a\n").unwrap();
git(&main, &["add", "-A"]);
git(&main, &["commit", "-q", "--no-verify", "-m", "a"]);
let worktree = base.join("wt");
git(
&main,
&[
"worktree",
"add",
"-q",
"--detach",
&worktree.display().to_string(),
],
);
assert_eq!(repo_layout(&plain), RepoLayout::NotGit);
assert_eq!(repo_layout(&main), RepoLayout::RepoRoot);
let normalized = |text: &str| text.replace('\\', "/").to_lowercase();
match repo_layout(&main.join("sub")) {
RepoLayout::Subdirectory { repo_root } => {
assert!(normalized(&repo_root).ends_with("/main"), "{repo_root}")
}
other => panic!("仓库子目录应判为 Subdirectory:{other:?}"),
}
match repo_layout(&worktree) {
RepoLayout::GitFile { hooks_dir } => assert!(
normalized(&hooks_dir).ends_with("/main/.git/hooks"),
"worktree 的 hook 在主仓库:{hooks_dir}"
),
other => panic!("worktree 应判为 GitFile:{other:?}"),
}
let _ = std::fs::remove_dir_all(&base);
}
}