From c1c2023879bcfda7563af30e70d9da3395c9e8c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:24:11 +0000 Subject: [PATCH 1/3] feat(compile): place output in directory when --output is a directory Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/9485542a-dae0-4395-9587-7f5ef457c5dd Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- AGENTS.md | 2 +- src/compile/mod.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 5 +++- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f90a7a50..358dd02f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1003,7 +1003,7 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - Creates `.github/agents/ado-aw.agent.md` — a Copilot dispatcher agent that routes to specialized prompts for creating, updating, and debugging agentic pipelines - The agent auto-downloads the ado-aw compiler and handles the full lifecycle (create → compile → check) - `compile []` - Compile a markdown file to Azure DevOps pipeline YAML. If no path is given, auto-discovers and recompiles all detected agentic pipelines in the current directory. - - `--output, -o ` - Optional output path for generated YAML (only valid when a path is provided) + - `--output, -o ` - Optional output path for the generated YAML (only valid when a path is provided). If the path is an existing directory, the compiled YAML is written inside that directory using the default filename derived from the markdown source (e.g. `foo.md` → `/foo.yml`). - `--skip-integrity` - *(debug builds only)* Omit the "Verify pipeline integrity" step from the generated pipeline. Useful during local development when the compiled output won't match a released compiler version. This flag is not available in release builds. - `--debug-pipeline` - *(debug builds only)* Include MCPG debug diagnostics in the generated pipeline: `DEBUG=*` environment variable for verbose MCPG logging, stderr streaming to log files, and a "Verify MCP backends" step that probes each backend with MCP initialize + tools/list before the agent runs. This flag is not available in release builds. - `check ` - Verify that a compiled pipeline matches its source markdown diff --git a/src/compile/mod.rs b/src/compile/mod.rs index f9dcd9ae..88d2cd8e 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -83,9 +83,23 @@ 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. When the caller passes an existing directory, + // place the compiled file inside it using the default filename derived + // from the input markdown's stem (e.g. `foo.md` -> `/foo.yml`). + let default_filename = input_path + .with_extension("yml") + .file_name() + .map(PathBuf::from) + .with_context(|| format!("Invalid input path: {}", input_path.display()))?; let yaml_output_path = match output_path { - Some(p) => PathBuf::from(p), + Some(p) => { + let p = PathBuf::from(p); + if p.is_dir() { + p.join(&default_filename) + } else { + p + } + } None => input_path.with_extension("yml"), }; @@ -676,4 +690,54 @@ Body diff ); } + + #[tokio::test] + async fn test_compile_pipeline_output_is_directory() { + // When --output points to an existing directory, the compiled YAML + // should be placed inside it using the default filename derived from + // the input markdown's stem. + let temp_dir = std::env::temp_dir().join(format!( + "ado-aw-compile-dir-output-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let input_path = temp_dir.join("my-agent.md"); + std::fs::write( + &input_path, + r#"--- +name: "Test Agent" +description: "A test agent for directory output" +--- + +## Body +"#, + ) + .unwrap(); + + let output_dir = temp_dir.join("out"); + std::fs::create_dir_all(&output_dir).unwrap(); + + compile_pipeline( + input_path.to_str().unwrap(), + Some(output_dir.to_str().unwrap()), + true, + false, + ) + .await + .expect("compile_pipeline should succeed"); + + let expected = output_dir.join("my-agent.yml"); + assert!( + expected.exists(), + "expected compiled YAML at {}", + expected.display() + ); + + let _ = std::fs::remove_dir_all(&temp_dir); + } } diff --git a/src/main.rs b/src/main.rs index 7d4dd245..9ab93123 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,10 @@ enum Commands { /// Path to the input markdown file. If omitted, auto-discovers and /// recompiles all existing agentic pipelines in the current directory. path: Option, - /// Optional output path for the generated YAML file + /// Optional output path for the generated YAML file. If the path + /// refers to an existing directory, the compiled YAML is written + /// inside that directory using the default filename derived from + /// the input markdown (e.g. `foo.md` -> `/foo.yml`). #[arg(short, long)] output: Option, /// Omit the "Verify pipeline integrity" step from the generated pipeline. From 62ab8f70a6e8bc6f520ea0f94684430be227ac7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:05:50 +0000 Subject: [PATCH 2/3] refactor(compile): compute default filename lazily; assert YAML content in test Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/f02521ba-67d6-456d-af2a-a228d50389ab Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/compile/mod.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 88d2cd8e..9ab468d1 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -86,16 +86,18 @@ pub async fn compile_pipeline( // Determine output path. When the caller passes an existing directory, // place the compiled file inside it using the default filename derived // from the input markdown's stem (e.g. `foo.md` -> `/foo.yml`). - let default_filename = input_path - .with_extension("yml") - .file_name() - .map(PathBuf::from) - .with_context(|| format!("Invalid input path: {}", input_path.display()))?; let yaml_output_path = match output_path { Some(p) => { let p = PathBuf::from(p); if p.is_dir() { - p.join(&default_filename) + let default_filename = input_path + .with_extension("yml") + .file_name() + .map(PathBuf::from) + .with_context(|| { + format!("Invalid input path: {}", input_path.display()) + })?; + p.join(default_filename) } else { p } @@ -737,6 +739,11 @@ description: "A test agent for directory output" "expected compiled YAML at {}", expected.display() ); + let contents = std::fs::read_to_string(&expected).unwrap(); + assert!( + contents.contains("@ado-aw"), + "expected compiled YAML to contain the @ado-aw source header" + ); let _ = std::fs::remove_dir_all(&temp_dir); } From d0a8d6b0f7251000e952f2e37f73d6a80d978511 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:35:29 +0000 Subject: [PATCH 3/3] Merge branch 'main' into copilot/place-compiled-agent-file Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/fa807a16-0f6a-4e5d-9bf7-9fbdc24c6de6 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- AGENTS.md | 2 +- 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 | 273 +++++++++++++++++++++++++ src/compile/mod.rs | 73 ++++++- src/data/init-agent.md | 2 +- src/main.rs | 2 +- tests/compiler_tests.rs | 2 +- 10 files changed, 366 insertions(+), 29 deletions(-) create mode 100644 src/compile/gitattributes.rs diff --git a/AGENTS.md b/AGENTS.md index 358dd02f..5d214a9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1003,7 +1003,7 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - Creates `.github/agents/ado-aw.agent.md` — a Copilot dispatcher agent that routes to specialized prompts for creating, updating, and debugging agentic pipelines - The agent auto-downloads the ado-aw compiler and handles the full lifecycle (create → compile → check) - `compile []` - Compile a markdown file to Azure DevOps pipeline YAML. If no path is given, auto-discovers and recompiles all detected agentic pipelines in the current directory. - - `--output, -o ` - Optional output path for the generated YAML (only valid when a path is provided). If the path is an existing directory, the compiled YAML is written inside that directory using the default filename derived from the markdown source (e.g. `foo.md` → `/foo.yml`). + - `--output, -o ` - Optional output path for the generated YAML (only valid when a path is provided). If the path is an existing directory, the compiled YAML is written inside that directory using the default filename derived from the markdown source (e.g. `foo.md` → `/foo.lock.yml`). - `--skip-integrity` - *(debug builds only)* Omit the "Verify pipeline integrity" step from the generated pipeline. Useful during local development when the compiled output won't match a released compiler version. This flag is not available in release builds. - `--debug-pipeline` - *(debug builds only)* Include MCPG debug diagnostics in the generated pipeline: `DEBUG=*` environment variable for verbose MCPG logging, stderr streaming to log files, and a "Verify MCP backends" step that probes each backend with MCP initialize + tools/list before the agent runs. This flag is not available in release builds. - `check ` - Verify that a compiled pipeline matches its source markdown 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..250c22e9 --- /dev/null +++ b/src/compile/gitattributes.rs @@ -0,0 +1,273 @@ +//! `.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 `./`. +/// +/// 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('\\', "/"); + 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); + + 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. 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; + + for line in content.split_inclusive('\n') { + let trimmed = line.trim_end_matches(['\n', '\r']); + if !in_block && trimmed == BEGIN_MARKER { + in_block = true; + continue; + } + if in_block { + if trimmed == END_MARKER { + in_block = false; + } + continue; + } + out.push_str(line); + } + + 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(); + + update_gitattributes(dir.path(), pipelines).await.unwrap(); + let second = std::fs::read_to_string(dir.path().join(".gitattributes")).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); + } + + #[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 9ab468d1..fe1fcb1d 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; @@ -53,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()); @@ -83,15 +99,18 @@ pub async fn compile_pipeline( // Validate checkout list against repositories common::validate_checkout_list(&front_matter.repositories, &front_matter.checkout)?; - // Determine output path. When the caller passes an existing directory, - // place the compiled file inside it using the default filename derived - // from the input markdown's stem (e.g. `foo.md` -> `/foo.yml`). + // 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`). When the + // caller passes an existing directory, place the compiled file inside + // it using the default filename derived from the input markdown's stem + // (e.g. `foo.md` -> `/foo.lock.yml`). let yaml_output_path = match output_path { Some(p) => { let p = PathBuf::from(p); if p.is_dir() { let default_filename = input_path - .with_extension("yml") + .with_extension("lock.yml") .file_name() .map(PathBuf::from) .with_context(|| { @@ -102,7 +121,7 @@ pub async fn compile_pipeline( p } } - None => input_path.with_extension("yml"), + None => input_path.with_extension("lock.yml"), }; // Select compiler based on target @@ -137,9 +156,38 @@ 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: 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). + // 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(()) } +/// 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, @@ -189,7 +237,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!( @@ -201,6 +249,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.", @@ -733,7 +792,7 @@ description: "A test agent for directory output" .await .expect("compile_pipeline should succeed"); - let expected = output_dir.join("my-agent.yml"); + let expected = output_dir.join("my-agent.lock.yml"); assert!( expected.exists(), "expected compiled YAML at {}", 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/src/main.rs b/src/main.rs index 9ab93123..c5a927d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,7 @@ enum Commands { /// Optional output path for the generated YAML file. If the path /// refers to an existing directory, the compiled YAML is written /// inside that directory using the default filename derived from - /// the input markdown (e.g. `foo.md` -> `/foo.yml`). + /// the input markdown (e.g. `foo.md` -> `/foo.lock.yml`). #[arg(short, long)] output: Option, /// Omit the "Verify pipeline integrity" step from the generated pipeline. 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!(