Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions crates/meshloop-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub enum Command {
Roles,
Doctor {
config: Option<PathBuf>,
db: Option<PathBuf>,
},
Status {
graph: Option<String>,
Expand Down Expand Up @@ -108,6 +109,7 @@ pub enum Command {
},
Bundle {
dest: PathBuf,
gitignore: bool,
},
AstSkeleton {
path: String,
Expand Down Expand Up @@ -168,6 +170,7 @@ fn parse_command(args: &[String]) -> Result<Command, String> {
Some(v) if v == "roles" => Ok(Command::Roles),
Some(v) if v == "doctor" => Ok(Command::Doctor {
config: flag_value(&args[1..], "--config").map(PathBuf::from),
db: flag_value(&args[1..], "--db").map(PathBuf::from),
}),
Some(v) if v == "plan" => {
let rest = &args[1..];
Expand Down Expand Up @@ -348,6 +351,7 @@ fn parse_command(args: &[String]) -> Result<Command, String> {
dest: flag_value(rest, "--dest")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("dist/meshloop-session-bundle")),
gitignore: has_flag(rest, "--gitignore"),
})
}
Some(v) if v == "ast-skeleton" => {
Expand Down Expand Up @@ -411,11 +415,11 @@ pub fn help_text() -> &'static str {
\x20 meshloop resume [--graph <id>] [--retry|--restart] [--json]\n\
\x20 meshloop status [--graph <id>] [--json]\n\
\x20 meshloop roles [--json]\n\
\x20 meshloop doctor [--config <path>] [--json]\n\
\x20 meshloop doctor [--config <path>] [--db <path>] [--json]\n\
\x20 meshloop orchestrate --task <id> --model-a <ref> --model-b <ref> [--json]\n\
\x20 meshloop mutate-plan [--graph <id>] (--mutation <json> | --mutation-file <path>) [--require-review] [--json]\n\
\x20 meshloop mcp\n\
\x20 meshloop bundle [--dest <dir>]\n\
\x20 meshloop bundle [--dest <dir>] [--gitignore]\n\
Origin (supervisor-only): --origin-harness <name> --origin-session <id>\n\
\x20 or MESHLOOP_ORIGIN_HARNESS / MESHLOOP_ORIGIN_SESSION.\n\
Live workers are the default (Herdr). --fixture-only forces the CI subprocess double."
Expand Down
1 change: 1 addition & 0 deletions crates/meshloop-cli/src/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ pub fn compose(req: ComposeRequest<'_>) -> Result<Composed, String> {
.map(Path::to_path_buf)
.unwrap_or_else(|| default_db(&req.repo_root));
let store = SqliteStore::open(&db).map_err(|e: StoreError| format!("{e:?}"))?;
crate::gitignore::ensure_store_ignored(&db);
let wt = req
.worktree_base
.unwrap_or_else(|| default_worktree_base(&req.repo_root));
Expand Down
152 changes: 152 additions & 0 deletions crates/meshloop-cli/src/gitignore.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//! Keeps the local `.meshloop/` store out of consuming repositories' Git status.
//! Probes use `git check-ignore` with structured arguments; nothing here edits a root
//! `.gitignore` except the explicit, opt-in `bundle --gitignore` block.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

pub const STORE_DIR: &str = ".meshloop";
pub const STORE_PROBE: &str = ".meshloop/state.sqlite";
pub const IGNORE_LINE: &str = "/.meshloop/";
const BLOCK_BEGIN: &str = "# >>> meshloop >>>";
const BLOCK_END: &str = "# <<< meshloop <<<";

/// Result of asking Git whether a path is ignored.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ignored {
Yes,
No,
/// Not inside a Git work tree (or Git is unavailable): nothing to flag.
NotApplicable,
}

fn git_ok(cwd: &Path, args: &[&str]) -> Option<(bool, i32)> {
let status = Command::new("git")
.current_dir(cwd)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.ok()?;
Some((status.success(), status.code().unwrap_or(-1)))
}

/// `git check-ignore -q <rel>` run in `cwd`: exit 0 ignored, 1 not ignored, else n/a.
pub fn check_ignored(cwd: &Path, rel: &str) -> Ignored {
match git_ok(cwd, &["check-ignore", "-q", "--", rel]) {
Some((true, _)) => Ignored::Yes,
Some((false, 1)) => Ignored::No,
_ => Ignored::NotApplicable,
}
}

fn in_work_tree(dir: &Path) -> bool {
let out = Command::new("git")
.current_dir(dir)
.args(["rev-parse", "--is-inside-work-tree"])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output();
matches!(out, Ok(o) if o.status.success() && o.stdout.starts_with(b"true"))
}

/// Ignore status of a store file. Paths outside any work tree are `NotApplicable`.
pub fn store_status(db: &Path) -> Ignored {
let Ok(db) = std::path::absolute(db) else {
return Ignored::NotApplicable;
};
let db = db.as_path();
let Some(parent) = db.parent() else {
return Ignored::NotApplicable;
};
let Some(base) = parent.ancestors().find(|a| a.is_dir()) else {
return Ignored::NotApplicable;
};
if !in_work_tree(base) {
return Ignored::NotApplicable;
}
match db.strip_prefix(base) {
Ok(rel) if !rel.as_os_str().is_empty() => check_ignored(base, &rel.to_string_lossy()),
_ => Ignored::NotApplicable,
}
}

/// `store_ignored` for `doctor`: true when ignored or not applicable.
pub fn store_ignored(db: &Path) -> bool {
store_status(db) != Ignored::No
}

/// After the store is created: if it sits in `.meshloop/` inside a work tree and is not
/// ignored, write `.meshloop/.gitignore` (`*`) and notify once on stderr. Never touches
/// the root `.gitignore`.
pub fn ensure_store_ignored(db: &Path) {
let Some(dir) = db.parent() else { return };
if dir.file_name().is_none_or(|n| n != STORE_DIR) || store_status(db) != Ignored::No {
return;
}
let marker = dir.join(".gitignore");
if marker.exists() {
return;
}
match fs::write(&marker, "*\n") {
Ok(()) => eprintln!(
"meshloop: wrote {} so the local store is not tracked by Git (one-time notice)",
marker.display()
),
Err(e) => eprintln!("warning: could not write {}: {e}", marker.display()),
}
}

/// Outcome of the `bundle` ignore check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BundleIgnore {
Present,
Missing,
Added,
NotApplicable,
}

impl BundleIgnore {
pub fn as_str(&self) -> &'static str {
match self {
Self::Present => "present",
Self::Missing => "missing",
Self::Added => "added",
Self::NotApplicable => "not-applicable",
}
}
}

fn marked_block() -> String {
format!("{BLOCK_BEGIN}\n{IGNORE_LINE}\n{BLOCK_END}\n")
}

/// Checks `repo` and, only when `apply` is set, appends the marked block (idempotent).
pub fn bundle_check(repo: &Path, apply: bool) -> Result<BundleIgnore, String> {
match check_ignored(repo, STORE_PROBE) {
Ignored::Yes => return Ok(BundleIgnore::Present),
Ignored::NotApplicable => return Ok(BundleIgnore::NotApplicable),
Ignored::No => {}
}
if !apply {
return Ok(BundleIgnore::Missing);
}
let path: PathBuf = repo.join(".gitignore");
let existing = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
};
if existing.contains(BLOCK_BEGIN) {
return Ok(BundleIgnore::Present);
}
let mut next = existing;
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
next.push_str(&marked_block());
fs::write(&path, next).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
Ok(BundleIgnore::Added)
}
56 changes: 50 additions & 6 deletions crates/meshloop-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod args;
mod compose;
mod config;
mod context_tools;
mod gitignore;
mod json_out;
mod mcp;
mod report;
Expand Down Expand Up @@ -81,7 +82,7 @@ fn dispatch(inv: Invocation) -> ExitCode {
}
Command::Mcp => ExitCode::from(mcp::run_stdio() as u8),
Command::Roles => cmd_roles(inv.json, inv.origin),
Command::Doctor { config } => cmd_doctor(config, inv.json, inv.origin),
Command::Doctor { config, db } => cmd_doctor(config, db, inv.json, inv.origin),
Command::Status { graph } => cmd_status(graph, inv.json, inv.origin),
Command::Plan {
objective,
Expand Down Expand Up @@ -235,7 +236,7 @@ fn dispatch(inv: Invocation) -> ExitCode {
inv.json,
inv.origin,
),
Command::Bundle { dest } => cmd_bundle(dest, inv.json, inv.origin),
Command::Bundle { dest, gitignore } => cmd_bundle(dest, gitignore, inv.json, inv.origin),
Command::AstSkeleton { path } => cmd_ast_skeleton(&path, inv.json, inv.origin),
Command::SymbolLookup { query, k, scope } => {
cmd_symbol_lookup(&query, k, scope.as_deref(), inv.json, inv.origin)
Expand Down Expand Up @@ -370,6 +371,10 @@ fn cmd_plan(
eprintln!("Run `meshloop doctor --config <path>` for per-harness readiness.");
return ExitCode::from(1);
}
Err(OrchestratorError::Plan(e)) => {
eprintln!("Decomposition failed: {e}");
return ExitCode::from(1);
}
Err(e) => {
eprintln!("Decomposition failed: {e:?}");
return ExitCode::from(1);
Expand Down Expand Up @@ -542,7 +547,12 @@ fn cmd_review_plan(
) {
Ok(g) => graph = g,
Err(e) => {
eprintln!("Adjust decomposition failed: {e:?}");
match &e {
OrchestratorError::Plan(p) => {
eprintln!("Adjust decomposition failed: {p}")
}
_ => eprintln!("Adjust decomposition failed: {e:?}"),
}
return ExitCode::from(1);
}
}
Expand Down Expand Up @@ -1371,9 +1381,16 @@ fn cmd_symbol_lookup(
}
}

fn cmd_bundle(dest: PathBuf, json: bool, origin: meshloop_engine::origin::Origin) -> ExitCode {
match session_bundle::write_to(&dest) {
Ok(files) => {
fn cmd_bundle(
dest: PathBuf,
gitignore: bool,
json: bool,
origin: meshloop_engine::origin::Origin,
) -> ExitCode {
let written = session_bundle::write_to(&dest)
.and_then(|files| gitignore::bundle_check(&dest, gitignore).map(|g| (files, g)));
match written {
Ok((files, ignore)) => {
if json {
let paths: Vec<String> = files
.iter()
Expand All @@ -1388,11 +1405,27 @@ fn cmd_bundle(dest: PathBuf, json: bool, origin: meshloop_engine::origin::Origin
"dest": dest.to_string_lossy(),
"version": env!("CARGO_PKG_VERSION"),
"files": paths,
"gitignore": ignore.as_str(),
"gitignore_line": gitignore::IGNORE_LINE,
}),
)
);
} else {
println!("bundled to {}", dest.display());
println!("gitignore: {}", ignore.as_str());
if ignore == gitignore::BundleIgnore::Missing {
eprintln!(
"add this line to {}/.gitignore (or rerun with --gitignore): {}",
dest.display(),
gitignore::IGNORE_LINE
);
}
}
if json && ignore == gitignore::BundleIgnore::Missing {
eprintln!(
"gitignore: missing; add `{}` to .gitignore (or rerun with --gitignore)",
gitignore::IGNORE_LINE
);
}
ExitCode::SUCCESS
}
Expand Down Expand Up @@ -1438,6 +1471,7 @@ fn cmd_roles(json: bool, origin: meshloop_engine::origin::Origin) -> ExitCode {

fn cmd_doctor(
config: Option<PathBuf>,
db: Option<PathBuf>,
json: bool,
origin: meshloop_engine::origin::Origin,
) -> ExitCode {
Expand Down Expand Up @@ -1466,6 +1500,8 @@ fn cmd_doctor(
},
};
let harnesses_ready = error.is_none();
let store_ignored =
gitignore::store_ignored(&db.unwrap_or_else(|| compose::default_db(&repo_root())));

let data = serde_json::json!({
"daemonless": true,
Expand All @@ -1481,6 +1517,7 @@ fn cmd_doctor(
"config": config_path.as_ref().map(|p| p.display().to_string()),
"harnesses_ready": harnesses_ready,
"harnesses": harnesses,
"store_ignored": store_ignored,
"note": "Doctor loads the config and runs each selected harness's bounded version probe. Live workers run as direct CLI subprocesses in Git worktrees.",
});
if json {
Expand All @@ -1507,6 +1544,13 @@ fn cmd_doctor(
};
println!(" harness {}: {status}", h["name"].as_str().unwrap_or("?"));
}
println!(" store_ignored: {store_ignored}");
if !store_ignored {
println!(
" fix: add `{}` to .gitignore, or run `meshloop bundle --dest . --gitignore`",
gitignore::IGNORE_LINE
);
}
if let Some(e) = &error {
println!(" error: {e}");
}
Expand Down
Loading
Loading