From b8bb98b9920764c6b325be57427aedffa9fe52ab Mon Sep 17 00:00:00 2001 From: Matt Walters Date: Fri, 13 Feb 2026 14:45:44 -0600 Subject: [PATCH 1/5] refactor: add clone_queue module, use config from meta_core Move CloneQueue/CloneTask from meta_git_cli into meta_git_lib as reusable queue logic. Update worktree helpers to import config from meta_core instead of meta_cli. Co-Authored-By: Claude Opus 4.6 --- src/clone_queue.rs | 497 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/worktree/helpers.rs | 29 +-- 3 files changed, 513 insertions(+), 14 deletions(-) create mode 100644 src/clone_queue.rs diff --git a/src/clone_queue.rs b/src/clone_queue.rs new file mode 100644 index 0000000..267ca40 --- /dev/null +++ b/src/clone_queue.rs @@ -0,0 +1,497 @@ +use log::debug; +use meta_core::config; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; + +/// A clone task representing a single repository to clone +#[derive(Debug, Clone)] +pub struct CloneTask { + /// Display name for progress output + pub name: String, + /// Git URL to clone from + pub url: String, + /// Target path to clone into + pub target_path: PathBuf, + /// Depth level (for display purposes) + pub depth_level: usize, + /// Whether this project is itself a meta-repo (declared with `meta: true` in config) + pub is_meta: bool, +} + +/// Thread-safe queue for managing clone tasks with dynamic discovery +pub struct CloneQueue { + /// Pending tasks to process + pending: Mutex>, + /// Completed task paths (to avoid duplicates) + completed: Mutex>, + /// Failed task paths + failed: Mutex>, + /// Total tasks discovered (for progress display) + total_discovered: AtomicUsize, + /// Total tasks completed + total_completed: AtomicUsize, + /// Git depth argument (if any) + git_depth: Option, + /// Max meta depth for recursion (None = unlimited) + meta_depth: Option, +} + +impl CloneQueue { + pub fn new(git_depth: Option, meta_depth: Option) -> Self { + Self { + pending: Mutex::new(Vec::new()), + completed: Mutex::new(HashSet::new()), + failed: Mutex::new(HashSet::new()), + total_discovered: AtomicUsize::new(0), + total_completed: AtomicUsize::new(0), + git_depth, + meta_depth, + } + } + + /// Add a task to the queue if not already completed or pending + pub fn push(&self, task: CloneTask) -> bool { + let path = task.target_path.clone(); + + // Check if already completed + { + let completed = self.completed.lock().unwrap_or_else(|e| e.into_inner()); + if completed.contains(&path) { + return false; + } + } + + // Add to pending + { + let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + // Check if already in pending + if pending.iter().any(|t| t.target_path == path) { + return false; + } + pending.push(task); + self.total_discovered.fetch_add(1, Ordering::SeqCst); + } + + true + } + + /// Add multiple tasks from a .meta file + pub fn push_from_meta(&self, base_dir: &Path, depth_level: usize) -> anyhow::Result { + // Check meta depth limit + if let Some(max_depth) = self.meta_depth { + if depth_level > max_depth { + debug!( + "Skipping nested discovery at depth {} (max: {})", + depth_level, max_depth + ); + return Ok(0); + } + } + + let Some((meta_path, _format)) = config::find_meta_config_in(base_dir) else { + debug!( + "No .meta config found in {}", + base_dir.display() + ); + return Ok(0); + }; + + let (projects, _) = config::parse_meta_config(&meta_path)?; + debug!( + "Discovered {} projects in {} at depth {}", + projects.len(), + base_dir.display(), + depth_level + ); + + let mut added = 0; + for project in projects { + let target_path = base_dir.join(&project.path); + + // Skip if already exists + if target_path.exists() { + // But still check if it has a config file for nested discovery + if config::find_meta_config_in(&target_path).is_some() { + // Queue it for discovery even though it's already cloned + added += self.push_from_meta(&target_path, depth_level + 1)?; + } + continue; + } + + // Skip projects without a repo URL (cannot clone) + let Some(url) = project.repo else { + continue; + }; + + let task = CloneTask { + name: project.name.clone(), + url, + target_path, + depth_level, + is_meta: project.meta, + }; + + let task_name = task.name.clone(); + let task_is_meta = task.is_meta; + if self.push(task) { + debug!( + "Queued clone task: {} (depth: {}, is_meta: {})", + task_name, depth_level, task_is_meta + ); + added += 1; + } + } + + Ok(added) + } + + /// Take a single task from the queue (for worker threads) + pub fn take_one(&self) -> Option { + let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + pending.pop() + } + + /// Check if queue is finished (no pending and no active workers) + pub fn is_finished(&self, active_workers: &AtomicUsize) -> bool { + let pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + pending.is_empty() && active_workers.load(Ordering::SeqCst) == 0 + } + + /// Drain all pending tasks (for dry-run display) + pub fn drain_all(&self) -> Vec { + let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + pending.drain(..).collect() + } + + /// Get current counts for display + pub fn get_counts(&self) -> (usize, usize) { + ( + self.total_completed.load(Ordering::SeqCst), + self.total_discovered.load(Ordering::SeqCst), + ) + } + + /// Get the git depth argument (if any) + pub fn git_depth(&self) -> Option<&str> { + self.git_depth.as_deref() + } + + /// Mark a task as completed and check for nested .meta files + pub fn mark_completed(&self, task: &CloneTask) -> anyhow::Result { + self.total_completed.fetch_add(1, Ordering::SeqCst); + + { + let mut completed = self.completed.lock().unwrap_or_else(|e| e.into_inner()); + completed.insert(task.target_path.clone()); + } + + // Check for nested .meta file and add children to queue + let added = self.push_from_meta(&task.target_path, task.depth_level + 1)?; + debug!( + "mark_completed: {} -> {} nested tasks discovered", + task.name, added + ); + + // Warn if the config declared meta: true but no nested .meta was found + if task.is_meta && added == 0 { + eprintln!( + "warning: '{}' is declared with `meta: true` but no .meta config was found inside it", + task.name + ); + } + + Ok(added) + } + + /// Mark a task as failed + pub fn mark_failed(&self, task: &CloneTask) { + self.total_completed.fetch_add(1, Ordering::SeqCst); + + let mut failed = self.failed.lock().unwrap_or_else(|e| e.into_inner()); + failed.insert(task.target_path.clone()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_task(name: &str, path: &Path) -> CloneTask { + CloneTask { + name: name.to_string(), + url: format!("git@github.com:org/{name}.git"), + target_path: path.to_path_buf(), + depth_level: 0, + is_meta: false, + } + } + + // ── push / dedup ────────────────────────────────────────── + + #[test] + fn push_adds_task_and_increments_count() { + let queue = CloneQueue::new(None, None); + let dir = tempfile::tempdir().unwrap(); + let task = make_task("repo1", &dir.path().join("repo1")); + + assert!(queue.push(task)); + assert_eq!(queue.total_discovered.load(Ordering::SeqCst), 1); + } + + #[test] + fn push_dedup_by_path() { + let queue = CloneQueue::new(None, None); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("repo1"); + + let task1 = make_task("repo1", &path); + let task2 = make_task("repo1-dup", &path); + + assert!(queue.push(task1)); + assert!(!queue.push(task2)); // same path → rejected + assert_eq!(queue.total_discovered.load(Ordering::SeqCst), 1); + } + + #[test] + fn push_rejects_completed_path() { + let queue = CloneQueue::new(None, None); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("repo1"); + + // Manually mark path as completed + { + let mut completed = queue.completed.lock().unwrap(); + completed.insert(path.clone()); + } + + let task = make_task("repo1", &path); + assert!(!queue.push(task)); + assert_eq!(queue.total_discovered.load(Ordering::SeqCst), 0); + } + + // ── take_one ────────────────────────────────────────────── + + #[test] + fn take_one_returns_none_when_empty() { + let queue = CloneQueue::new(None, None); + assert!(queue.take_one().is_none()); + } + + #[test] + fn take_one_returns_task_when_available() { + let queue = CloneQueue::new(None, None); + let dir = tempfile::tempdir().unwrap(); + queue.push(make_task("repo1", &dir.path().join("repo1"))); + + let task = queue.take_one(); + assert!(task.is_some()); + assert_eq!(task.unwrap().name, "repo1"); + assert!(queue.take_one().is_none()); // now empty + } + + // ── is_finished ─────────────────────────────────────────── + + #[test] + fn is_finished_true_when_empty_and_no_active() { + let queue = CloneQueue::new(None, None); + let active = AtomicUsize::new(0); + assert!(queue.is_finished(&active)); + } + + #[test] + fn is_finished_false_when_pending() { + let queue = CloneQueue::new(None, None); + let dir = tempfile::tempdir().unwrap(); + queue.push(make_task("repo1", &dir.path().join("repo1"))); + + let active = AtomicUsize::new(0); + assert!(!queue.is_finished(&active)); + } + + #[test] + fn is_finished_false_when_active_workers() { + let queue = CloneQueue::new(None, None); + let active = AtomicUsize::new(1); + assert!(!queue.is_finished(&active)); + } + + // ── push_from_meta ──────────────────────────────────────── + + #[test] + fn push_from_meta_discovers_projects() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": { + "alpha": "git@github.com:org/alpha.git", + "beta": "git@github.com:org/beta.git" + }}"#, + ) + .unwrap(); + + let queue = CloneQueue::new(None, None); + let added = queue.push_from_meta(dir.path(), 0).unwrap(); + assert_eq!(added, 2); + assert_eq!(queue.total_discovered.load(Ordering::SeqCst), 2); + } + + #[test] + fn push_from_meta_no_config_returns_zero() { + let dir = tempfile::tempdir().unwrap(); + let queue = CloneQueue::new(None, None); + let added = queue.push_from_meta(dir.path(), 0).unwrap(); + assert_eq!(added, 0); + } + + #[test] + fn push_from_meta_respects_depth_limit() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"repo": "git@github.com:org/repo.git"}}"#, + ) + .unwrap(); + + // meta_depth = Some(0) means only depth 0 is allowed + let queue = CloneQueue::new(None, Some(0)); + let added = queue.push_from_meta(dir.path(), 0).unwrap(); + assert_eq!(added, 1); // depth 0 is allowed + + // Trying at depth 1 should be blocked + let added = queue.push_from_meta(dir.path(), 1).unwrap(); + assert_eq!(added, 0); + } + + #[test] + fn push_from_meta_skips_existing_dirs() { + let dir = tempfile::tempdir().unwrap(); + // Create the project directory so it appears "already cloned" + std::fs::create_dir(dir.path().join("existing")).unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"existing": "git@github.com:org/existing.git"}}"#, + ) + .unwrap(); + + let queue = CloneQueue::new(None, None); + let added = queue.push_from_meta(dir.path(), 0).unwrap(); + assert_eq!(added, 0); // skipped because dir exists + } + + #[test] + fn push_from_meta_skips_projects_without_repo() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"no-repo": {"path": "no-repo"}}}"#, + ) + .unwrap(); + + let queue = CloneQueue::new(None, None); + let added = queue.push_from_meta(dir.path(), 0).unwrap(); + assert_eq!(added, 0); + } + + #[test] + fn push_from_meta_preserves_is_meta_flag() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": { + "nested": {"repo": "git@github.com:org/nested.git", "meta": true}, + "plain": "git@github.com:org/plain.git" + }}"#, + ) + .unwrap(); + + let queue = CloneQueue::new(None, None); + queue.push_from_meta(dir.path(), 0).unwrap(); + + let tasks = queue.drain_all(); + let nested = tasks.iter().find(|t| t.name == "nested").unwrap(); + let plain = tasks.iter().find(|t| t.name == "plain").unwrap(); + + assert!(nested.is_meta); + assert!(!plain.is_meta); + } + + // ── mark_completed / nested discovery ───────────────────── + + #[test] + fn mark_completed_discovers_nested_meta() { + let dir = tempfile::tempdir().unwrap(); + let child_dir = dir.path().join("child"); + std::fs::create_dir(&child_dir).unwrap(); + + // Child has its own .meta with a grandchild + std::fs::write( + child_dir.join(".meta"), + r#"{"projects": {"grandchild": "git@github.com:org/grandchild.git"}}"#, + ) + .unwrap(); + + let queue = CloneQueue::new(None, None); + let task = CloneTask { + name: "child".to_string(), + url: "git@github.com:org/child.git".to_string(), + target_path: child_dir, + depth_level: 0, + is_meta: true, + }; + + let added = queue.mark_completed(&task).unwrap(); + assert_eq!(added, 1); + assert_eq!(queue.total_discovered.load(Ordering::SeqCst), 1); + } + + #[test] + fn mark_completed_no_nested_meta() { + let dir = tempfile::tempdir().unwrap(); + let child_dir = dir.path().join("child"); + std::fs::create_dir(&child_dir).unwrap(); + // No .meta inside child + + let queue = CloneQueue::new(None, None); + let task = CloneTask { + name: "child".to_string(), + url: "git@github.com:org/child.git".to_string(), + target_path: child_dir, + depth_level: 0, + is_meta: false, + }; + + let added = queue.mark_completed(&task).unwrap(); + assert_eq!(added, 0); + } + + // ── get_counts ──────────────────────────────────────────── + + #[test] + fn get_counts_tracks_discovered_and_completed() { + let dir = tempfile::tempdir().unwrap(); + let queue = CloneQueue::new(None, None); + + queue.push(make_task("a", &dir.path().join("a"))); + queue.push(make_task("b", &dir.path().join("b"))); + + let (completed, discovered) = queue.get_counts(); + assert_eq!(discovered, 2); + assert_eq!(completed, 0); + } + + // ── drain_all ───────────────────────────────────────────── + + #[test] + fn drain_all_empties_queue() { + let dir = tempfile::tempdir().unwrap(); + let queue = CloneQueue::new(None, None); + queue.push(make_task("a", &dir.path().join("a"))); + queue.push(make_task("b", &dir.path().join("b"))); + + let tasks = queue.drain_all(); + assert_eq!(tasks.len(), 2); + assert!(queue.take_one().is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 43536a7..864bc91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ use anyhow::Result; use indicatif::ProgressBar; use std::path::Path; use std::process::{Command, Stdio}; +pub mod clone_queue; pub mod missing; pub mod snapshot; pub mod ssh_multiplexing; diff --git a/src/worktree/helpers.rs b/src/worktree/helpers.rs index f962adb..01684b1 100644 --- a/src/worktree/helpers.rs +++ b/src/worktree/helpers.rs @@ -78,7 +78,7 @@ pub fn read_worktrees_dir_from_config(meta_dir: &Path) -> Option { pub fn find_meta_dir() -> Option { let cwd = std::env::current_dir().ok()?; - meta_cli::config::find_meta_config(&cwd, None) + meta_core::config::find_meta_config(&cwd, None) .map(|(path, _)| path.parent().unwrap_or(Path::new(".")).to_path_buf()) } @@ -124,10 +124,10 @@ pub fn discover_and_validate_worktree( } /// Load and parse the .meta config, returning the project list. -pub fn load_projects(meta_dir: &Path) -> Result> { - let (config_path, _) = meta_cli::config::find_meta_config(meta_dir, None) +pub fn load_projects(meta_dir: &Path) -> Result> { + let (config_path, _) = meta_core::config::find_meta_config(meta_dir, None) .ok_or_else(|| anyhow::anyhow!("No .meta config found in {}", meta_dir.display()))?; - let (projects, _) = meta_cli::config::parse_meta_config(&config_path)?; + let (projects, _) = meta_core::config::parse_meta_config(&config_path)?; Ok(projects) } @@ -144,20 +144,21 @@ pub fn load_projects(meta_dir: &Path) -> Result Result> { +) -> Result> { let mut projects = load_projects(meta_dir)?; if include_root && meta_dir.join(".git").exists() { // Prepend root repo so it's processed first (dependencies come first) projects.insert( 0, - meta_cli::config::ProjectInfo { + meta_core::config::ProjectInfo { name: ".".to_string(), path: ".".to_string(), repo: None, // Root repo doesn't have a remote URL in this context tags: vec![], provides: vec![], depends_on: vec![], + meta: false, }, ); } @@ -167,9 +168,9 @@ pub fn load_projects_with_root( /// Look up a project by alias, returning an error with valid aliases on miss. pub fn lookup_project<'a>( - projects: &'a [meta_cli::config::ProjectInfo], + projects: &'a [meta_core::config::ProjectInfo], alias: &str, -) -> Result<&'a meta_cli::config::ProjectInfo> { +) -> Result<&'a meta_core::config::ProjectInfo> { projects.iter().find(|p| p.name == alias).ok_or_else(|| { let valid: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect(); anyhow::anyhow!( @@ -189,13 +190,13 @@ pub fn lookup_project<'a>( pub fn lookup_nested_project( meta_dir: &Path, alias: &str, -) -> Result<(PathBuf, meta_cli::config::ProjectInfo)> { +) -> Result<(PathBuf, meta_core::config::ProjectInfo)> { // If alias contains '/', use recursive lookup if alias.contains('/') { - let tree = meta_cli::config::walk_meta_tree(meta_dir, None)?; + let tree = meta_core::config::walk_meta_tree(meta_dir, None)?; // Build a map of full path -> ProjectInfo - let project_map = meta_cli::config::build_project_map(&tree, meta_dir, ""); + let project_map = meta_core::config::build_project_map(&tree, meta_dir, ""); project_map.get(alias).cloned().ok_or_else(|| { // Use keys from the map we already built (avoids re-walking the tree) @@ -684,7 +685,7 @@ mod tests { assert!(err.contains("Unknown repo alias")); } - // ── build_project_map (via meta_cli::config) ────────────── + // ── build_project_map (via meta_core::config) ────────────── #[test] fn build_project_map_handles_nested_structure() { @@ -704,8 +705,8 @@ mod tests { ) .unwrap(); - let tree = meta_cli::config::walk_meta_tree(tmp.path(), None).unwrap(); - let map = meta_cli::config::build_project_map(&tree, tmp.path(), ""); + let tree = meta_core::config::walk_meta_tree(tmp.path(), None).unwrap(); + let map = meta_core::config::build_project_map(&tree, tmp.path(), ""); // Should contain both vendor and vendor/lib assert!(map.contains_key("vendor")); From 44beae2d9580d423654fb6760fccecd763c8f891 Mon Sep 17 00:00:00 2001 From: Matt Walters Date: Fri, 13 Feb 2026 15:26:05 -0600 Subject: [PATCH 2/5] =?UTF-8?q?fix(review):=20TOCTOU=20fix,=20eprintln?= =?UTF-8?q?=E2=86=92log::warn,=20serde=5Fyaml=E2=86=92serde=5Fyml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review feedback: - Fix TOCTOU race in CloneQueue::push() by holding both completed and pending locks atomically during the check-and-insert - Replace eprintln! with log::warn! for meta: true warning, consistent with rest of module's use of log crate - Replace deprecated serde_yaml with serde_yml Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 2 +- src/clone_queue.rs | 32 ++++++++++++++------------------ src/worktree/helpers.rs | 2 +- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 36693f0..9bf5bee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ chrono = { version = "0.4", features = ["serde"] } log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -serde_yaml = "0.9" +serde_yml = "0.0.12" [dev-dependencies] tempfile = "3.3" diff --git a/src/clone_queue.rs b/src/clone_queue.rs index 267ca40..3776a13 100644 --- a/src/clone_queue.rs +++ b/src/clone_queue.rs @@ -1,4 +1,4 @@ -use log::debug; +use log::{debug, warn}; use meta_core::config; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -55,24 +55,20 @@ impl CloneQueue { pub fn push(&self, task: CloneTask) -> bool { let path = task.target_path.clone(); - // Check if already completed - { - let completed = self.completed.lock().unwrap_or_else(|e| e.into_inner()); - if completed.contains(&path) { - return false; - } + // Lock both completed and pending atomically to prevent TOCTOU races + let completed = self.completed.lock().unwrap_or_else(|e| e.into_inner()); + if completed.contains(&path) { + return false; } - // Add to pending - { - let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); - // Check if already in pending - if pending.iter().any(|t| t.target_path == path) { - return false; - } - pending.push(task); - self.total_discovered.fetch_add(1, Ordering::SeqCst); + let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + if pending.iter().any(|t| t.target_path == path) { + return false; } + pending.push(task); + drop(pending); + drop(completed); + self.total_discovered.fetch_add(1, Ordering::SeqCst); true } @@ -196,8 +192,8 @@ impl CloneQueue { // Warn if the config declared meta: true but no nested .meta was found if task.is_meta && added == 0 { - eprintln!( - "warning: '{}' is declared with `meta: true` but no .meta config was found inside it", + warn!( + "'{}' is declared with `meta: true` but no .meta config was found inside it", task.name ); } diff --git a/src/worktree/helpers.rs b/src/worktree/helpers.rs index 01684b1..10876e1 100644 --- a/src/worktree/helpers.rs +++ b/src/worktree/helpers.rs @@ -59,7 +59,7 @@ pub fn read_meta_config_value(meta_dir: &Path) -> Option { return Some(v); } // Try YAML - if let Ok(v) = serde_yaml::from_str::(&content) { + if let Ok(v) = serde_yml::from_str::(&content) { // Convert YAML Value to JSON Value for uniform access if let Ok(json_val) = serde_json::to_value(v) { return Some(json_val); From d87213c133669340ad95a84a8e596c5b59d72e91 Mon Sep 17 00:00:00 2001 From: Matt Walters Date: Fri, 13 Feb 2026 16:15:28 -0600 Subject: [PATCH 3/5] ci: add workspace setup and smart branch cloning CI was failing because the repo uses workspace-inherited fields (version.workspace = true) but was checked out in isolation without a workspace root. Add proper workspace setup and smart branch cloning that tries the PR branch name first before falling back to main. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/auto-format.yml | 33 +++++++++++-- .github/workflows/ci.yml | 82 ++++++++++++++++++++++++++++--- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index 3865df5..2a34772 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -17,9 +17,6 @@ jobs: format: name: Auto Format runs-on: ubuntu-latest - # Skip if: - # - PR from fork (can't push back) - # - Commit by github-actions[bot] (prevent infinite loop) if: | (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'github-actions[bot]' @@ -27,18 +24,45 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.head_ref || github.ref_name }} - # Use PAT to trigger downstream workflows; fallback to GITHUB_TOKEN + path: meta_git_lib token: ${{ secrets.PARENT_REPO_PAT || github.token }} + - name: Clone dependencies + run: | + BRANCH="${{ github.head_ref || github.ref_name }}" + for repo in meta_core meta_cli meta_plugin_protocol loop_lib; do + git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null || \ + git clone --depth 1 "https://github.com/harmony-labs/${repo}.git" + done + + - name: Create workspace Cargo.toml + run: | + cat > Cargo.toml << 'EOF' + [workspace] + members = ["meta_git_lib", "meta_core", "meta_cli", "meta_plugin_protocol", "loop_lib"] + resolver = "2" + + [workspace.package] + version = "0.1.0" + edition = "2021" + license = "MIT" + repository = "https://github.com/harmony-labs/meta" + EOF + + - name: Create VERSION file + run: echo "0.0.0-ci" > VERSION + - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - name: Run cargo fmt + working-directory: meta_git_lib run: cargo fmt --all - name: Check for changes id: changes + working-directory: meta_git_lib run: | if git diff --quiet; then echo "formatted=false" >> $GITHUB_OUTPUT @@ -48,6 +72,7 @@ jobs: - name: Commit and push if: steps.changes.outputs.formatted == 'true' + working-directory: meta_git_lib run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c90af01..33fb64e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,10 +26,30 @@ jobs: - name: Clone dependencies shell: bash run: | - git clone --depth 1 https://github.com/harmony-labs/meta_core.git - git clone --depth 1 https://github.com/harmony-labs/meta_cli.git - git clone --depth 1 https://github.com/harmony-labs/meta_plugin_protocol.git - git clone --depth 1 https://github.com/harmony-labs/loop_lib.git + BRANCH="${{ github.head_ref || github.ref_name }}" + for repo in meta_core meta_cli meta_plugin_protocol loop_lib; do + git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null || \ + git clone --depth 1 "https://github.com/harmony-labs/${repo}.git" + done + + - name: Create workspace Cargo.toml + shell: bash + run: | + cat > Cargo.toml << 'EOF' + [workspace] + members = ["meta_git_lib", "meta_core", "meta_cli", "meta_plugin_protocol", "loop_lib"] + resolver = "2" + + [workspace.package] + version = "0.1.0" + edition = "2021" + license = "MIT" + repository = "https://github.com/harmony-labs/meta" + EOF + + - name: Create VERSION file + shell: bash + run: echo "0.0.0-ci" > VERSION - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -54,10 +74,28 @@ jobs: - name: Clone dependencies run: | - git clone --depth 1 https://github.com/harmony-labs/meta_core.git - git clone --depth 1 https://github.com/harmony-labs/meta_cli.git - git clone --depth 1 https://github.com/harmony-labs/meta_plugin_protocol.git - git clone --depth 1 https://github.com/harmony-labs/loop_lib.git + BRANCH="${{ github.head_ref || github.ref_name }}" + for repo in meta_core meta_cli meta_plugin_protocol loop_lib; do + git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null || \ + git clone --depth 1 "https://github.com/harmony-labs/${repo}.git" + done + + - name: Create workspace Cargo.toml + run: | + cat > Cargo.toml << 'EOF' + [workspace] + members = ["meta_git_lib", "meta_core", "meta_cli", "meta_plugin_protocol", "loop_lib"] + resolver = "2" + + [workspace.package] + version = "0.1.0" + edition = "2021" + license = "MIT" + repository = "https://github.com/harmony-labs/meta" + EOF + + - name: Create VERSION file + run: echo "0.0.0-ci" > VERSION - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -79,6 +117,33 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + path: meta_git_lib + + - name: Clone dependencies + run: | + BRANCH="${{ github.head_ref || github.ref_name }}" + for repo in meta_core meta_cli meta_plugin_protocol loop_lib; do + git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null || \ + git clone --depth 1 "https://github.com/harmony-labs/${repo}.git" + done + + - name: Create workspace Cargo.toml + run: | + cat > Cargo.toml << 'EOF' + [workspace] + members = ["meta_git_lib", "meta_core", "meta_cli", "meta_plugin_protocol", "loop_lib"] + resolver = "2" + + [workspace.package] + version = "0.1.0" + edition = "2021" + license = "MIT" + repository = "https://github.com/harmony-labs/meta" + EOF + + - name: Create VERSION file + run: echo "0.0.0-ci" > VERSION - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -86,4 +151,5 @@ jobs: components: rustfmt - name: Check formatting + working-directory: meta_git_lib run: cargo fmt --all -- --check From 5aec2c9df0467fbca05caabc202abf728e01e4b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 13 Feb 2026 22:16:18 +0000 Subject: [PATCH 4/5] style: auto-format code --- src/clone_queue.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/clone_queue.rs b/src/clone_queue.rs index 3776a13..849a8c3 100644 --- a/src/clone_queue.rs +++ b/src/clone_queue.rs @@ -87,10 +87,7 @@ impl CloneQueue { } let Some((meta_path, _format)) = config::find_meta_config_in(base_dir) else { - debug!( - "No .meta config found in {}", - base_dir.display() - ); + debug!("No .meta config found in {}", base_dir.display()); return Ok(0); }; From 9719de36d9e406b04f9548ccad85ded5648e0de5 Mon Sep 17 00:00:00 2001 From: Matt Walters Date: Fri, 13 Feb 2026 18:26:59 -0600 Subject: [PATCH 5/5] fix: replace serde_yml with serde_yaml_ng and harden CI - Replace serde_yml (RUSTSEC-2025-0068: unsound, unmaintained) with serde_yaml_ng (maintained drop-in replacement) - Fix CI script injection: move github.head_ref to env: instead of inline shell interpolation - Add clone failure handling and branch logging in CI Co-Authored-By: Claude Opus 4.6 --- .github/workflows/auto-format.yml | 13 ++++++++--- .github/workflows/ci.yml | 39 ++++++++++++++++++++++++------- Cargo.toml | 2 +- src/worktree/helpers.rs | 2 +- 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index 2a34772..492aae7 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -28,11 +28,18 @@ jobs: token: ${{ secrets.PARENT_REPO_PAT || github.token }} - name: Clone dependencies + env: + BRANCH: ${{ github.head_ref || github.ref_name }} run: | - BRANCH="${{ github.head_ref || github.ref_name }}" for repo in meta_core meta_cli meta_plugin_protocol loop_lib; do - git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/harmony-labs/${repo}.git" + if git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null; then + echo "Cloned ${repo}@${BRANCH}" + elif git clone --depth 1 "https://github.com/harmony-labs/${repo}.git"; then + echo "Branch ${BRANCH} not found for ${repo}; falling back to default branch" + else + echo "::error::Failed to clone ${repo}" + exit 1 + fi done - name: Create workspace Cargo.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33fb64e..c778f3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,11 +25,18 @@ jobs: - name: Clone dependencies shell: bash + env: + BRANCH: ${{ github.head_ref || github.ref_name }} run: | - BRANCH="${{ github.head_ref || github.ref_name }}" for repo in meta_core meta_cli meta_plugin_protocol loop_lib; do - git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/harmony-labs/${repo}.git" + if git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null; then + echo "Cloned ${repo}@${BRANCH}" + elif git clone --depth 1 "https://github.com/harmony-labs/${repo}.git"; then + echo "Branch ${BRANCH} not found for ${repo}; falling back to default branch" + else + echo "::error::Failed to clone ${repo}" + exit 1 + fi done - name: Create workspace Cargo.toml @@ -73,11 +80,18 @@ jobs: path: meta_git_lib - name: Clone dependencies + env: + BRANCH: ${{ github.head_ref || github.ref_name }} run: | - BRANCH="${{ github.head_ref || github.ref_name }}" for repo in meta_core meta_cli meta_plugin_protocol loop_lib; do - git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/harmony-labs/${repo}.git" + if git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null; then + echo "Cloned ${repo}@${BRANCH}" + elif git clone --depth 1 "https://github.com/harmony-labs/${repo}.git"; then + echo "Branch ${BRANCH} not found for ${repo}; falling back to default branch" + else + echo "::error::Failed to clone ${repo}" + exit 1 + fi done - name: Create workspace Cargo.toml @@ -121,11 +135,18 @@ jobs: path: meta_git_lib - name: Clone dependencies + env: + BRANCH: ${{ github.head_ref || github.ref_name }} run: | - BRANCH="${{ github.head_ref || github.ref_name }}" for repo in meta_core meta_cli meta_plugin_protocol loop_lib; do - git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/harmony-labs/${repo}.git" + if git clone --depth 1 -b "$BRANCH" "https://github.com/harmony-labs/${repo}.git" 2>/dev/null; then + echo "Cloned ${repo}@${BRANCH}" + elif git clone --depth 1 "https://github.com/harmony-labs/${repo}.git"; then + echo "Branch ${BRANCH} not found for ${repo}; falling back to default branch" + else + echo "::error::Failed to clone ${repo}" + exit 1 + fi done - name: Create workspace Cargo.toml diff --git a/Cargo.toml b/Cargo.toml index 9bf5bee..5a90ff8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ chrono = { version = "0.4", features = ["serde"] } log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -serde_yml = "0.0.12" +serde_yaml_ng = "0.10" [dev-dependencies] tempfile = "3.3" diff --git a/src/worktree/helpers.rs b/src/worktree/helpers.rs index 10876e1..1827f89 100644 --- a/src/worktree/helpers.rs +++ b/src/worktree/helpers.rs @@ -59,7 +59,7 @@ pub fn read_meta_config_value(meta_dir: &Path) -> Option { return Some(v); } // Try YAML - if let Ok(v) = serde_yml::from_str::(&content) { + if let Ok(v) = serde_yaml_ng::from_str::(&content) { // Convert YAML Value to JSON Value for uniform access if let Ok(json_val) = serde_json::to_value(v) { return Some(json_val);