From 185a431c764af0527a43bdce64c13b3d80285a19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:48:41 +0000 Subject: [PATCH 1/3] feat: emit .lock.yml for compiled pipelines and manage .gitattributes Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/6964fcee-affd-47fd-bce6-3168d0b89d53 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- README.md | 15 +- prompts/create-ado-agentic-workflow.md | 12 +- prompts/debug-ado-agentic-workflow.md | 12 +- prompts/update-ado-agentic-workflow.md | 2 +- src/compile/gitattributes.rs | 255 +++++++++++++++++++++++++ src/compile/mod.rs | 32 +++- src/data/init-agent.md | 2 +- tests/compiler_tests.rs | 2 +- 8 files changed, 310 insertions(+), 22 deletions(-) create mode 100644 src/compile/gitattributes.rs diff --git a/README.md b/README.md index 98aaed42..8da1eab3 100644 --- a/README.md +++ b/README.md @@ -107,22 +107,27 @@ request with a clear description of what changed and why. ### 4. Compile to a Pipeline ```bash -# Simple form — generates the .yml alongside the source .md +# Simple form — generates a `.lock.yml` alongside the source `.md` ado-aw compile dependency-updater.md # Or specify a custom output location -ado-aw compile dependency-updater.md -o path/to/dependency-updater.yml +ado-aw compile dependency-updater.md -o path/to/dependency-updater.lock.yml ``` This generates a complete Azure DevOps pipeline YAML file. The compiler also copies the agent markdown body into the output tree so it's available at runtime. +The compiler also writes/updates a `.gitattributes` file at the repository root +that marks every compiled `.lock.yml` pipeline as `linguist-generated=true merge=ours`, +so GitHub hides them from PR diffs and merge conflicts in generated YAML resolve +to the local copy (which can then be rebuilt with `ado-aw compile`). + ### 5. Verify (CI Check) Ensure pipelines stay in sync with their source: ```bash -ado-aw check dependency-updater.yml +ado-aw check dependency-updater.lock.yml ``` This is useful as a CI gate — if someone edits the markdown but forgets to @@ -134,7 +139,7 @@ recompile, the check will fail. ### Step 1: Commit both files -Your repo should contain the agent source `.md` and the compiled pipeline `.yml`. +Your repo should contain the agent source `.md` and the compiled pipeline `.lock.yml`. Place them wherever your team's conventions dictate — there is no required directory structure. Push both files to your Azure DevOps repository. @@ -144,7 +149,7 @@ Push both files to your Azure DevOps repository. 1. Go to **Pipelines → New Pipeline** 2. Select your repository 3. Choose **Existing Azure Pipelines YAML file** -4. Point to the compiled `.yml` pipeline file +4. Point to the compiled `.lock.yml` pipeline file 5. Save (or Save & Run) ### Step 3: Set Up ARM Service Connections for Permissions diff --git a/prompts/create-ado-agentic-workflow.md b/prompts/create-ado-agentic-workflow.md index b227e73e..ad1db77b 100644 --- a/prompts/create-ado-agentic-workflow.md +++ b/prompts/create-ado-agentic-workflow.md @@ -489,14 +489,14 @@ When generating the agent file: After creating the agent file, compile it into an Azure DevOps pipeline: ```bash -# Simple form — generates the .yml pipeline alongside the .md source +# Simple form — generates a `.lock.yml` pipeline alongside the `.md` source ado-aw compile # Or specify a custom output location -ado-aw compile -o +ado-aw compile -o ``` -This generates a `.yml` pipeline file. Both the source `.md` and generated `.yml` must be committed together. +This generates a `.lock.yml` pipeline file. Both the source `.md` and generated `.lock.yml` must be committed together. The compiler also writes/updates a `.gitattributes` file at the repository root so compiled pipelines are marked `linguist-generated=true merge=ours`. If the `ado-aw` CLI is not installed or not available on `PATH`, guide the user to download it from: https://github.com/githubnext/ado-aw/releases @@ -506,8 +506,8 @@ https://github.com/githubnext/ado-aw/releases ``` Next steps: 1. Review and customize the agent instructions in .md - 2. Commit both the .md source and the generated .yml pipeline - 3. Register the .yml as a pipeline in Azure DevOps + 2. Commit both the .md source, the generated .lock.yml pipeline, and any .gitattributes changes + 3. Register the .lock.yml as a pipeline in Azure DevOps ``` --- @@ -595,5 +595,5 @@ safe-outputs: - **Minimal permissions**: Default to no permissions; add only what the task requires. - **Explicit allow-lists**: Restrict MCP tools to only what the agent needs. - **No direct writes**: All mutations go through safe outputs — the agent cannot push code or call write APIs directly. -- **Compile before committing**: Always compile with `ado-aw compile` and commit both the `.md` source and generated `.yml` together. +- **Compile before committing**: Always compile with `ado-aw compile` and commit both the `.md` source and generated `.lock.yml` together. - **Check validation**: The compiler will error if write safe-outputs are configured without `permissions.write`. diff --git a/prompts/debug-ado-agentic-workflow.md b/prompts/debug-ado-agentic-workflow.md index ae306985..c5086d24 100644 --- a/prompts/debug-ado-agentic-workflow.md +++ b/prompts/debug-ado-agentic-workflow.md @@ -44,7 +44,7 @@ Follow this sequence for every debugging session: 3. **Check for compilation drift** — before deep-diving into runtime errors, verify the pipeline YAML is in sync with its source markdown: ```bash - ado-aw check + ado-aw check ``` 4. **Apply the fix** — make the targeted change to the agent `.md` source file, then recompile: @@ -178,17 +178,17 @@ network: **Diagnosis**: ```bash -ado-aw check +ado-aw check ``` If the check fails, the pipeline YAML is out of sync with the source markdown. This happens when: - The `.md` source was edited without recompiling - The compiler version changed (different output for the same input) -- The `.yml` was manually edited +- The `.lock.yml` was manually edited **Fix**: Recompile and commit both files together: ```bash -ado-aw compile -o +ado-aw compile -o ``` --- @@ -350,7 +350,7 @@ If downloads fail: ```bash # Verify pipeline YAML matches its source markdown -ado-aw check +ado-aw check # Recompile a single agent ado-aw compile @@ -371,7 +371,7 @@ ado-aw configure --dry-run Use this checklist to systematically rule out common issues: -- [ ] **Compilation in sync**: `ado-aw check ` passes +- [ ] **Compilation in sync**: `ado-aw check ` passes - [ ] **Correct stage identified**: Know which of the 3 jobs failed - [ ] **Network allowlist**: All required domains are in `network.allowed` or built-in - [ ] **MCP tools allowed**: Every tool the agent needs is in an `allowed:` list diff --git a/prompts/update-ado-agentic-workflow.md b/prompts/update-ado-agentic-workflow.md index b42ef3cd..94c937f0 100644 --- a/prompts/update-ado-agentic-workflow.md +++ b/prompts/update-ado-agentic-workflow.md @@ -336,7 +336,7 @@ After completing an update: Next steps: 1. Review the changes in .md 2. Recompile: ado-aw compile - 3. Commit both the updated .md source and regenerated .yml pipeline + 3. Commit both the updated .md source and regenerated .lock.yml pipeline ``` If only agent instructions were changed: diff --git a/src/compile/gitattributes.rs b/src/compile/gitattributes.rs new file mode 100644 index 00000000..f7cf251c --- /dev/null +++ b/src/compile/gitattributes.rs @@ -0,0 +1,255 @@ +//! `.gitattributes` management for compiled pipelines. +//! +//! Compiled pipeline files are generated artifacts: they should be marked as +//! linguist-generated (so GitHub UI hides them from PR reviews and language +//! statistics) and use the `merge=ours` strategy (so merge conflicts in the +//! generated YAML are resolved by keeping the local copy and re-running +//! `ado-aw compile`). +//! +//! The compiler manages a clearly delimited block in `/.gitattributes`. +//! User-managed entries outside the block are preserved. + +use anyhow::{Context, Result}; +use std::collections::BTreeSet; +use std::path::Path; + +const BEGIN_MARKER: &str = "# BEGIN ado-aw managed (do not edit)"; +const END_MARKER: &str = "# END ado-aw managed"; +const ATTRIBUTES: &str = "linguist-generated=true merge=ours"; + +/// Update the managed block of `/.gitattributes` so that exactly +/// the supplied compiled-pipeline paths are marked as generated. +/// +/// Each entry takes the form ` linguist-generated=true merge=ours`. +/// Paths are normalized to forward slashes and de-duplicated. +/// +/// Existing user-managed lines outside the block are preserved verbatim. +/// If `pipelines` is empty, the managed block is removed entirely. +pub async fn update_gitattributes>( + repo_root: &Path, + pipelines: impl IntoIterator, +) -> Result<()> { + let path = repo_root.join(".gitattributes"); + + let existing = match tokio::fs::read_to_string(&path).await { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => { + return Err(e).with_context(|| { + format!("Failed to read existing {}", path.display()) + }) + } + }; + + let entries: BTreeSet = pipelines + .into_iter() + .map(|p| normalize_path(p.as_ref())) + .collect(); + + let new_content = render(&existing, &entries); + + if new_content == existing { + return Ok(()); + } + + tokio::fs::write(&path, new_content) + .await + .with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) +} + +/// Normalize a path to forward slashes and strip any leading `./`. +fn normalize_path(p: &Path) -> String { + let s = p.to_string_lossy().replace('\\', "/"); + s.trim_start_matches("./").to_string() +} + +/// Compute the new file contents given the existing file and the desired +/// managed entries. +fn render(existing: &str, entries: &BTreeSet) -> String { + let preserved = strip_managed_block(existing); + + if entries.is_empty() { + // Nothing to manage — leave only the user-managed portion. + return preserved; + } + + let mut block = String::new(); + block.push_str(BEGIN_MARKER); + block.push('\n'); + for entry in entries { + block.push_str(entry); + block.push(' '); + block.push_str(ATTRIBUTES); + block.push('\n'); + } + block.push_str(END_MARKER); + block.push('\n'); + + if preserved.is_empty() { + block + } else if preserved.ends_with('\n') { + format!("{}{}", preserved, block) + } else { + format!("{}\n{}", preserved, block) + } +} + +/// Remove any existing managed block (between BEGIN and END markers) from +/// `content`. Lines outside the markers are preserved verbatim. +fn strip_managed_block(content: &str) -> String { + let mut out = String::new(); + let mut in_block = false; + let mut found_end = true; + + for line in content.split_inclusive('\n') { + let trimmed = line.trim_end_matches(['\n', '\r']); + if !in_block && trimmed == BEGIN_MARKER { + in_block = true; + found_end = false; + continue; + } + if in_block { + if trimmed == END_MARKER { + in_block = false; + found_end = true; + } + continue; + } + out.push_str(line); + } + + // If we entered the block and never found the end marker, we've already + // stripped to end-of-file, which is the safest behavior. + let _ = found_end; + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[tokio::test] + async fn writes_new_gitattributes_when_missing() { + let dir = tempfile::tempdir().unwrap(); + let pipelines = vec![ + PathBuf::from("agents/my-agent.lock.yml"), + PathBuf::from(".azdo/pipelines/review.lock.yml"), + ]; + update_gitattributes(dir.path(), pipelines).await.unwrap(); + + let written = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap(); + assert!(written.contains(BEGIN_MARKER)); + assert!(written.contains(END_MARKER)); + assert!( + written.contains(".azdo/pipelines/review.lock.yml linguist-generated=true merge=ours") + ); + assert!( + written.contains("agents/my-agent.lock.yml linguist-generated=true merge=ours") + ); + } + + #[tokio::test] + async fn preserves_user_managed_lines() { + let dir = tempfile::tempdir().unwrap(); + let user = "*.png binary\n# my own comment\n"; + std::fs::write(dir.path().join(".gitattributes"), user).unwrap(); + + update_gitattributes( + dir.path(), + vec![PathBuf::from("agents/x.lock.yml")], + ) + .await + .unwrap(); + + let written = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap(); + assert!(written.starts_with("*.png binary\n# my own comment\n")); + assert!(written.contains("agents/x.lock.yml linguist-generated=true merge=ours")); + } + + #[tokio::test] + async fn replaces_existing_managed_block() { + let dir = tempfile::tempdir().unwrap(); + let initial = format!( + "*.png binary\n{}\nstale/path.lock.yml linguist-generated=true merge=ours\n{}\n", + BEGIN_MARKER, END_MARKER + ); + std::fs::write(dir.path().join(".gitattributes"), initial).unwrap(); + + update_gitattributes( + dir.path(), + vec![PathBuf::from("new/path.lock.yml")], + ) + .await + .unwrap(); + + let written = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap(); + assert!(written.starts_with("*.png binary\n")); + assert!(!written.contains("stale/path.lock.yml")); + assert!(written.contains("new/path.lock.yml linguist-generated=true merge=ours")); + // Block markers should appear exactly once + assert_eq!(written.matches(BEGIN_MARKER).count(), 1); + assert_eq!(written.matches(END_MARKER).count(), 1); + } + + #[tokio::test] + async fn removes_block_when_no_pipelines() { + let dir = tempfile::tempdir().unwrap(); + let initial = format!( + "*.png binary\n{}\nold/path.lock.yml linguist-generated=true merge=ours\n{}\n", + BEGIN_MARKER, END_MARKER + ); + std::fs::write(dir.path().join(".gitattributes"), initial).unwrap(); + + update_gitattributes(dir.path(), Vec::::new()).await.unwrap(); + + let written = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap(); + assert_eq!(written, "*.png binary\n"); + } + + #[tokio::test] + async fn entries_are_sorted_and_deduplicated() { + let dir = tempfile::tempdir().unwrap(); + let pipelines = vec![ + PathBuf::from("./b/x.lock.yml"), + PathBuf::from("a/y.lock.yml"), + PathBuf::from("b/x.lock.yml"), // duplicate after normalization + ]; + update_gitattributes(dir.path(), pipelines).await.unwrap(); + + let written = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap(); + let body: Vec<&str> = written + .lines() + .filter(|l| l.contains("linguist-generated")) + .collect(); + assert_eq!(body.len(), 2); + assert!(body[0].starts_with("a/y.lock.yml ")); + assert!(body[1].starts_with("b/x.lock.yml ")); + } + + #[tokio::test] + async fn idempotent_when_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let pipelines = vec![PathBuf::from("agents/x.lock.yml")]; + update_gitattributes(dir.path(), pipelines.clone()).await.unwrap(); + let first = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap(); + let mtime_before = std::fs::metadata(dir.path().join(".gitattributes")) + .unwrap() + .modified() + .unwrap(); + + // Sleep briefly so a rewrite would change mtime + std::thread::sleep(std::time::Duration::from_millis(20)); + + update_gitattributes(dir.path(), pipelines).await.unwrap(); + let second = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap(); + let mtime_after = std::fs::metadata(dir.path().join(".gitattributes")) + .unwrap() + .modified() + .unwrap(); + + assert_eq!(first, second); + assert_eq!(mtime_before, mtime_after, "file should not be rewritten"); + } +} diff --git a/src/compile/mod.rs b/src/compile/mod.rs index f9dcd9ae..b7e21d7d 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -8,6 +8,7 @@ mod common; pub mod extensions; +mod gitattributes; mod onees; mod standalone; pub mod types; @@ -83,10 +84,12 @@ pub async fn compile_pipeline( // Validate checkout list against repositories common::validate_checkout_list(&front_matter.repositories, &front_matter.checkout)?; - // Determine output path + // Determine output path. By default use `.lock.yml` to match + // gh-aw's convention for compiled-pipeline files (so they can be + // marked as generated and merge=ours via `.gitattributes`). let yaml_output_path = match output_path { Some(p) => PathBuf::from(p), - None => input_path.with_extension("yml"), + None => input_path.with_extension("lock.yml"), }; // Select compiler based on target @@ -121,9 +124,34 @@ pub async fn compile_pipeline( yaml_output_path.display() ); + // Update .gitattributes at the repo root so every compiled pipeline is + // marked as a generated file with `merge=ours`. Best-effort: silently + // skip when the output is not inside a git repository. + if let Err(e) = sync_gitattributes_for_output(&yaml_output_path).await { + debug!("Skipped .gitattributes update: {}", e); + } + Ok(()) } +/// Locate the repo root containing `output_path`, scan it for all compiled +/// pipelines, and write the managed block of `.gitattributes`. +async fn sync_gitattributes_for_output(output_path: &Path) -> Result<()> { + let abs = if output_path.is_absolute() { + output_path.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(output_path) + }; + let repo_root = find_repo_root(&abs) + .with_context(|| format!("no .git directory found above {}", output_path.display()))?; + + let detected = crate::detect::detect_pipelines(&repo_root).await?; + let paths: Vec = detected.into_iter().map(|p| p.yaml_path).collect(); + gitattributes::update_gitattributes(&repo_root, paths).await +} + /// Auto-discover and recompile all agentic pipelines in the current directory. /// /// Scans for compiled YAML files containing the `# @ado-aw source=...` header, diff --git a/src/data/init-agent.md b/src/data/init-agent.md index 820a23af..1897fc91 100644 --- a/src/data/init-agent.md +++ b/src/data/init-agent.md @@ -94,7 +94,7 @@ When a user interacts with you: /tmp/ado-aw compile # Verify pipeline matches source -/tmp/ado-aw check +/tmp/ado-aw check ``` ## Key Features diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 738f36f1..974f48ac 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1597,7 +1597,7 @@ This agent tests the auto-discovery feature. ); // Verify the YAML was created with the header - let yaml_path = agents_dir.join("my-agent.yml"); + let yaml_path = agents_dir.join("my-agent.lock.yml"); assert!(yaml_path.exists(), "Compiled YAML should exist"); let initial_yaml = fs::read_to_string(&yaml_path).expect("Should read initial YAML"); assert!( From 164b5aa6c74227e2d7f7e3acfe1b3d64f3a1dac1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:53:18 +0000 Subject: [PATCH 2/3] refactor: address code review nits in gitattributes module Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/6964fcee-affd-47fd-bce6-3168d0b89d53 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/compile/gitattributes.rs | 10 +++------- src/compile/mod.rs | 5 +++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/compile/gitattributes.rs b/src/compile/gitattributes.rs index f7cf251c..65ca74f8 100644 --- a/src/compile/gitattributes.rs +++ b/src/compile/gitattributes.rs @@ -96,32 +96,28 @@ fn render(existing: &str, entries: &BTreeSet) -> String { } /// Remove any existing managed block (between BEGIN and END markers) from -/// `content`. Lines outside the markers are preserved verbatim. +/// `content`. Lines outside the markers are preserved verbatim. If the BEGIN +/// marker appears without a matching END, everything from BEGIN to EOF is +/// stripped (treated as a corrupted/truncated managed block). fn strip_managed_block(content: &str) -> String { let mut out = String::new(); let mut in_block = false; - let mut found_end = true; for line in content.split_inclusive('\n') { let trimmed = line.trim_end_matches(['\n', '\r']); if !in_block && trimmed == BEGIN_MARKER { in_block = true; - found_end = false; continue; } if in_block { if trimmed == END_MARKER { in_block = false; - found_end = true; } continue; } out.push_str(line); } - // If we entered the block and never found the end marker, we've already - // stripped to end-of-file, which is the safest behavior. - let _ = found_end; out } diff --git a/src/compile/mod.rs b/src/compile/mod.rs index b7e21d7d..73e2e1ab 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -125,8 +125,9 @@ pub async fn compile_pipeline( ); // Update .gitattributes at the repo root so every compiled pipeline is - // marked as a generated file with `merge=ours`. Best-effort: silently - // skip when the output is not inside a git repository. + // marked as a generated file with `merge=ours`. Best-effort: skip with a + // debug-level log when the output is not inside a git repository, since + // a non-git workspace is a valid use case (e.g. ad-hoc compilation). if let Err(e) = sync_gitattributes_for_output(&yaml_output_path).await { debug!("Skipped .gitattributes update: {}", e); } From bda23509c22685370d5fca4394a7b74675daf420 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:34:47 +0000 Subject: [PATCH 3/3] fix: address rust-review feedback on .gitattributes writer Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/8a920650-7872-4658-9e7a-85642e24bc6b Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/compile/gitattributes.rs | 48 ++++++++++++++++++++++++++---------- src/compile/mod.rs | 35 +++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/compile/gitattributes.rs b/src/compile/gitattributes.rs index 65ca74f8..250c22e9 100644 --- a/src/compile/gitattributes.rs +++ b/src/compile/gitattributes.rs @@ -59,13 +59,29 @@ pub async fn update_gitattributes>( } /// Normalize a path to forward slashes and strip any leading `./`. +/// +/// The `.gitattributes` format treats whitespace as a separator between the +/// pattern and the attributes, so any pattern containing a space, `"`, or `#` +/// must be wrapped in double quotes (with embedded `"` escaped) for git to +/// parse it as a single pattern. Paths without those characters are emitted +/// unquoted to keep the file readable. fn normalize_path(p: &Path) -> String { let s = p.to_string_lossy().replace('\\', "/"); - s.trim_start_matches("./").to_string() + let s = s.trim_start_matches("./"); + if s.contains(' ') || s.contains('"') || s.contains('#') { + format!("\"{}\"", s.replace('"', "\\\"")) + } else { + s.to_string() + } } /// Compute the new file contents given the existing file and the desired /// managed entries. +/// +/// The managed block is always written at the end of the file. If a user has +/// previously placed the block elsewhere (e.g. between user-managed entries), +/// the first recompile will move it to EOF; user lines outside the block are +/// preserved verbatim either way. fn render(existing: &str, entries: &BTreeSet) -> String { let preserved = strip_managed_block(existing); @@ -230,22 +246,28 @@ mod tests { let pipelines = vec![PathBuf::from("agents/x.lock.yml")]; update_gitattributes(dir.path(), pipelines.clone()).await.unwrap(); let first = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap(); - let mtime_before = std::fs::metadata(dir.path().join(".gitattributes")) - .unwrap() - .modified() - .unwrap(); - - // Sleep briefly so a rewrite would change mtime - std::thread::sleep(std::time::Duration::from_millis(20)); update_gitattributes(dir.path(), pipelines).await.unwrap(); let second = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap(); - let mtime_after = std::fs::metadata(dir.path().join(".gitattributes")) - .unwrap() - .modified() - .unwrap(); + // Content equality is the contract; the writer additionally + // short-circuits the on-disk write when contents already match (see + // `update_gitattributes`), but we don't assert mtime here because + // mtime granularity varies by filesystem (e.g. 1s on macOS HFS+). assert_eq!(first, second); - assert_eq!(mtime_before, mtime_after, "file should not be rewritten"); + } + + #[tokio::test] + async fn quotes_paths_containing_spaces() { + let dir = tempfile::tempdir().unwrap(); + let pipelines = vec![PathBuf::from("my agents/pipeline.lock.yml")]; + update_gitattributes(dir.path(), pipelines).await.unwrap(); + + let written = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap(); + assert!( + written.contains("\"my agents/pipeline.lock.yml\" linguist-generated=true merge=ours"), + "expected quoted path entry, got:\n{}", + written + ); } } diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 73e2e1ab..9a1b77ac 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -54,6 +54,21 @@ pub async fn compile_pipeline( output_path: Option<&str>, skip_integrity: bool, debug_pipeline: bool, +) -> Result<()> { + compile_pipeline_inner(input_path, output_path, skip_integrity, debug_pipeline, true).await +} + +/// Internal compile entry point that lets the caller opt out of the +/// per-invocation `.gitattributes` sync. Batch callers +/// (`compile_all_pipelines`) skip the per-pipeline sync to avoid an +/// O(N²)-ish series of full-tree scans and instead perform a single sync +/// after the whole batch completes. +async fn compile_pipeline_inner( + input_path: &str, + output_path: Option<&str>, + skip_integrity: bool, + debug_pipeline: bool, + sync_gitattributes: bool, ) -> Result<()> { let input_path = Path::new(input_path); info!("Compiling pipeline from: {}", input_path.display()); @@ -128,8 +143,11 @@ pub async fn compile_pipeline( // marked as a generated file with `merge=ours`. Best-effort: skip with a // debug-level log when the output is not inside a git repository, since // a non-git workspace is a valid use case (e.g. ad-hoc compilation). - if let Err(e) = sync_gitattributes_for_output(&yaml_output_path).await { - debug!("Skipped .gitattributes update: {}", e); + // Skipped during batch compilation (callers do one sync at the end). + if sync_gitattributes { + if let Err(e) = sync_gitattributes_for_output(&yaml_output_path).await { + debug!("Skipped .gitattributes update: {}", e); + } } Ok(()) @@ -202,7 +220,7 @@ pub async fn compile_all_pipelines(skip_integrity: bool, debug_pipeline: bool) - let source_str = source_path.to_string_lossy(); let output_str = yaml_output_path.to_string_lossy(); - match compile_pipeline(&source_str, Some(&output_str), skip_integrity, debug_pipeline).await { + match compile_pipeline_inner(&source_str, Some(&output_str), skip_integrity, debug_pipeline, false).await { Ok(()) => success_count += 1, Err(e) => { eprintln!( @@ -214,6 +232,17 @@ pub async fn compile_all_pipelines(skip_integrity: bool, debug_pipeline: bool) - } } + // One .gitattributes sync after the whole batch — avoids the N+1 scans + // that would happen if each pipeline triggered its own + // `sync_gitattributes_for_output` call. We reuse the already-detected + // pipeline list rather than re-scanning the tree. + if let Some(repo_root) = find_repo_root(&std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) { + let paths: Vec = detected.iter().map(|p| p.yaml_path.clone()).collect(); + if let Err(e) = gitattributes::update_gitattributes(&repo_root, paths).await { + debug!("Skipped .gitattributes update: {}", e); + } + } + println!(); println!( "Done: {} compiled, {} skipped, {} failed.",