Skip to content

Commit 32137fe

Browse files
fix: preserve subdirectory in generated pipeline_path and source_path (#114)
* Initial plan * fix: preserve directory path in generated pipeline_path and source_path When compiling from a directory that contains a subdirectory with the agent markdown (e.g. `ado-aw compile agents/ctf.md` from the repo root), the generated pipeline embedded only the filename in pipeline_path and source_path, dropping the subdirectory prefix. Root causes: - generate_pipeline_path used file_name() only → agents/ctf.yml became {{ workspace }}/ctf.yml instead of {{ workspace }}/agents/ctf.yml - generate_source_path used file_name() only and hardcoded the agents/ prefix → would break for files outside agents/ Fix: - Both functions now preserve the full relative path via a shared normalize_relative_path helper (strips leading ./ and normalizes slashes) - Absolute paths fall back to filename-only to avoid embedding machine-specific paths in the pipeline YAML - Added 8 unit tests covering the corrected behaviour Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/13f2c5e7-28fe-4ffb-9460-dd868098ce0c Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * fix: address review feedback on normalize_relative_path - Use strip_prefix idiom instead of manual index slicing in ./ loop - Add doc note that .. components are the caller's responsibility - Add absolute-path fallback tests for both generate_source_path and generate_pipeline_path Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/a8c9ad7f-e06e-40d8-98bf-25160a4183d8 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * fix: handle absolute input paths via git-root relativization When an absolute path is passed (e.g. ado-aw compile /home/user/repo/agents/ctf.md), the previous code fell back to filename-only, losing the directory component and causing the integrity check to fail. - Add find_git_root() to walk up the directory tree looking for a .git entry - Update normalize_relative_path() to use it for absolute paths, computing a path relative to the git repo root instead of falling back to filename-only - Fall back to filename-only only when no git root is found - Add tests covering the git-root happy path (using tempfile::TempDir for safe cleanup) and the no-git-root fallback for both functions Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/75c10525-fed4-4c3f-a784-ede99d43f058 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent 1ee02e4 commit 32137fe

1 file changed

Lines changed: 206 additions & 12 deletions

File tree

src/compile/common.rs

Lines changed: 206 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -500,26 +500,112 @@ pub fn generate_header_comment(input_path: &std::path::Path) -> String {
500500
///
501501
/// Returns a path using `{{ workspace }}` as the base, which gets resolved
502502
/// to the correct ADO working directory before this placeholder is replaced.
503+
///
504+
/// The full relative path of the input file is preserved so that agents compiled
505+
/// from subdirectories (e.g. `ado-aw compile agents/ctf.md`) produce a correct
506+
/// runtime path (`$(Build.SourcesDirectory)/agents/ctf.md`) rather than a path
507+
/// that drops the directory component.
508+
///
509+
/// Absolute paths fall back to using only the filename to avoid embedding
510+
/// machine-specific paths in the generated pipeline.
503511
pub fn generate_source_path(input_path: &std::path::Path) -> String {
504-
let filename = input_path
505-
.file_name()
506-
.and_then(|n| n.to_str())
507-
.unwrap_or("agent.md");
508-
509-
format!("{{{{ workspace }}}}/agents/{}", filename)
512+
let relative = normalize_relative_path(input_path).unwrap_or_else(|| {
513+
input_path
514+
.file_name()
515+
.and_then(|n| n.to_str())
516+
.unwrap_or("agent.md")
517+
.to_string()
518+
});
519+
520+
format!("{{{{ workspace }}}}/{}", relative)
510521
}
511522

512523
/// Generate the pipeline YAML path for integrity checking at ADO runtime.
513524
///
514525
/// Returns a path using `{{ workspace }}` as the base, derived from the
515-
/// output path's filename so it matches whatever `-o` was specified during compilation.
526+
/// output path so it matches whatever `-o` was specified during compilation.
527+
///
528+
/// The full relative path is preserved so that pipelines compiled into
529+
/// subdirectories (e.g. `agents/ctf.yml`) produce a correct runtime path
530+
/// (`$(Build.SourcesDirectory)/agents/ctf.yml`) rather than a path that
531+
/// drops the directory component.
532+
///
533+
/// Absolute paths fall back to using only the filename to avoid embedding
534+
/// machine-specific paths in the generated pipeline.
516535
pub fn generate_pipeline_path(output_path: &std::path::Path) -> String {
517-
let filename = output_path
518-
.file_name()
519-
.and_then(|n| n.to_str())
520-
.unwrap_or("pipeline.yml");
536+
let relative = normalize_relative_path(output_path).unwrap_or_else(|| {
537+
output_path
538+
.file_name()
539+
.and_then(|n| n.to_str())
540+
.unwrap_or("pipeline.yml")
541+
.to_string()
542+
});
543+
544+
format!("{{{{ workspace }}}}/{}", relative)
545+
}
546+
547+
/// Normalize a path for embedding in a generated pipeline.
548+
///
549+
/// Returns `Some(String)` when `path` is relative, with:
550+
/// - Backslashes converted to forward slashes
551+
/// - Redundant leading `./` prefixes stripped
552+
///
553+
/// For absolute paths the function first tries to compute a relative path from
554+
/// the nearest git repository root (found by walking up the directory tree
555+
/// looking for a `.git` entry). This preserves the directory structure when
556+
/// the user passes an absolute path — e.g.
557+
/// `/home/user/repo/agents/ctf.md` → `agents/ctf.md`.
558+
///
559+
/// Falls back to `None` (callers use filename-only) only when no git root is
560+
/// found, to avoid embedding machine-specific absolute paths in the generated
561+
/// pipeline YAML.
562+
///
563+
/// Note: `..` components in relative paths are passed through unchanged.
564+
/// Callers are responsible for ensuring the path does not traverse outside the
565+
/// repository checkout.
566+
fn normalize_relative_path(path: &std::path::Path) -> Option<String> {
567+
if path.is_absolute() {
568+
// Try to make the path relative to the nearest git repo root so that
569+
// directory structure (e.g. `agents/ctf.md`) is preserved even when
570+
// the user invokes the compiler with an absolute path.
571+
if let Some(git_root) = find_git_root(path) {
572+
if let Ok(rel) = path.strip_prefix(&git_root) {
573+
let s = rel.to_string_lossy().replace('\\', "/");
574+
return Some(s);
575+
}
576+
}
577+
return None;
578+
}
579+
580+
let mut s = path.to_string_lossy().replace('\\', "/");
581+
while let Some(stripped) = s.strip_prefix("./") {
582+
s = stripped.to_string();
583+
}
584+
Some(s)
585+
}
586+
587+
/// Walk up the directory tree from `path` looking for a `.git` entry.
588+
///
589+
/// Returns the first ancestor directory that contains `.git`, or `None` if the
590+
/// traversal reaches the filesystem root without finding one.
591+
fn find_git_root(path: &std::path::Path) -> Option<std::path::PathBuf> {
592+
// Start from the file's parent directory (or the path itself if it is a dir).
593+
let start: &std::path::Path = if path.is_dir() {
594+
path
595+
} else {
596+
path.parent()?
597+
};
521598

522-
format!("{{{{ workspace }}}}/{}", filename)
599+
let mut current = start.to_path_buf();
600+
loop {
601+
if current.join(".git").exists() {
602+
return Some(current);
603+
}
604+
match current.parent() {
605+
Some(parent) => current = parent.to_path_buf(),
606+
None => return None,
607+
}
608+
}
523609
}
524610

525611
// ==================== Permission helpers ====================
@@ -1067,4 +1153,112 @@ mod tests {
10671153
header
10681154
);
10691155
}
1156+
1157+
// ─── generate_source_path ────────────────────────────────────────────────
1158+
1159+
#[test]
1160+
fn test_generate_source_path_preserves_directory() {
1161+
// Compiling agents/ctf.md should produce {{ workspace }}/agents/ctf.md,
1162+
// not {{ workspace }}/agents/ctf.md with a hardcoded agents/ prefix.
1163+
let path = std::path::Path::new("agents/ctf.md");
1164+
let result = generate_source_path(path);
1165+
assert_eq!(result, "{{ workspace }}/agents/ctf.md");
1166+
}
1167+
1168+
#[test]
1169+
fn test_generate_source_path_nested_directory() {
1170+
let path = std::path::Path::new("pipelines/production/review.md");
1171+
let result = generate_source_path(path);
1172+
assert_eq!(result, "{{ workspace }}/pipelines/production/review.md");
1173+
}
1174+
1175+
#[test]
1176+
fn test_generate_source_path_strips_dot_slash() {
1177+
let path = std::path::Path::new("./agents/my-agent.md");
1178+
let result = generate_source_path(path);
1179+
assert_eq!(result, "{{ workspace }}/agents/my-agent.md");
1180+
}
1181+
1182+
#[test]
1183+
fn test_generate_source_path_filename_only() {
1184+
let path = std::path::Path::new("my-agent.md");
1185+
let result = generate_source_path(path);
1186+
assert_eq!(result, "{{ workspace }}/my-agent.md");
1187+
}
1188+
1189+
// ─── generate_pipeline_path ──────────────────────────────────────────────
1190+
1191+
#[test]
1192+
fn test_generate_pipeline_path_preserves_directory() {
1193+
// The original bug: compiling agents/ctf.md produced agents/ctf.yml as
1194+
// output, but the embedded path was only ctf.yml (missing agents/).
1195+
let path = std::path::Path::new("agents/ctf.yml");
1196+
let result = generate_pipeline_path(path);
1197+
assert_eq!(result, "{{ workspace }}/agents/ctf.yml");
1198+
}
1199+
1200+
#[test]
1201+
fn test_generate_pipeline_path_nested_directory() {
1202+
let path = std::path::Path::new("pipelines/production/review.yml");
1203+
let result = generate_pipeline_path(path);
1204+
assert_eq!(result, "{{ workspace }}/pipelines/production/review.yml");
1205+
}
1206+
1207+
#[test]
1208+
fn test_generate_pipeline_path_strips_dot_slash() {
1209+
let path = std::path::Path::new("./agents/my-agent.yml");
1210+
let result = generate_pipeline_path(path);
1211+
assert_eq!(result, "{{ workspace }}/agents/my-agent.yml");
1212+
}
1213+
1214+
#[test]
1215+
fn test_generate_pipeline_path_filename_only() {
1216+
let path = std::path::Path::new("pipeline.yml");
1217+
let result = generate_pipeline_path(path);
1218+
assert_eq!(result, "{{ workspace }}/pipeline.yml");
1219+
}
1220+
1221+
#[test]
1222+
fn test_generate_source_path_absolute_falls_back_to_filename() {
1223+
// /home/user/agents/ctf.md is not inside a git repo, so we fall back
1224+
// to filename-only to avoid embedding a machine-specific absolute path.
1225+
let path = std::path::Path::new("/home/user/agents/ctf.md");
1226+
let result = generate_source_path(path);
1227+
assert_eq!(result, "{{ workspace }}/ctf.md");
1228+
}
1229+
1230+
#[test]
1231+
fn test_generate_pipeline_path_absolute_falls_back_to_filename() {
1232+
let path = std::path::Path::new("/home/user/agents/ctf.yml");
1233+
let result = generate_pipeline_path(path);
1234+
assert_eq!(result, "{{ workspace }}/ctf.yml");
1235+
}
1236+
1237+
#[test]
1238+
fn test_generate_source_path_absolute_with_git_root_preserves_directory() {
1239+
// When the absolute path is inside a git repo, the directory structure
1240+
// relative to the repo root must be preserved.
1241+
use std::fs;
1242+
let tmp = tempfile::TempDir::new().unwrap();
1243+
let agents_dir = tmp.path().join("agents");
1244+
fs::create_dir_all(&agents_dir).unwrap();
1245+
// A `.git` file (as used in worktrees) satisfies `.exists()` just like
1246+
// a `.git` directory, so either form is a valid marker.
1247+
fs::write(tmp.path().join(".git"), "gitdir: fake").unwrap();
1248+
let abs_path = agents_dir.join("ctf.md");
1249+
let result = generate_source_path(&abs_path);
1250+
assert_eq!(result, "{{ workspace }}/agents/ctf.md");
1251+
}
1252+
1253+
#[test]
1254+
fn test_generate_pipeline_path_absolute_with_git_root_preserves_directory() {
1255+
use std::fs;
1256+
let tmp = tempfile::TempDir::new().unwrap();
1257+
let agents_dir = tmp.path().join("agents");
1258+
fs::create_dir_all(&agents_dir).unwrap();
1259+
fs::write(tmp.path().join(".git"), "gitdir: fake").unwrap();
1260+
let abs_path = agents_dir.join("ctf.yml");
1261+
let result = generate_pipeline_path(&abs_path);
1262+
assert_eq!(result, "{{ workspace }}/agents/ctf.yml");
1263+
}
10701264
}

0 commit comments

Comments
 (0)