diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 880885c..479634a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,12 @@ permissions: jobs: verify: - name: Verify - runs-on: ubuntu-24.04 + name: Verify (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-latest, windows-latest] steps: - name: Check out repository uses: actions/checkout@v5 diff --git a/src/cli/commit/mod.rs b/src/cli/commit/mod.rs index aaa5513..a973c70 100644 --- a/src/cli/commit/mod.rs +++ b/src/cli/commit/mod.rs @@ -136,7 +136,35 @@ mod tests { use crate::core::commit::{CommitEntry, CommitOptions, CommitOutcome}; use crate::core::restack::RestackPreview; use clap::FromArgMatches; - use std::os::unix::process::ExitStatusExt; + use std::process::ExitStatus; + + /// Create an `ExitStatus` representing a successful (code 0) process. + fn exit_status_success() -> ExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(0) + } + #[cfg(windows)] + { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(0) + } + } + + /// Create an `ExitStatus` representing a failed (non-zero) process. + fn exit_status_failure() -> ExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(1 << 8) // encodes exit code 1 + } + #[cfg(windows)] + { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(1) + } + } #[test] fn converts_cli_args_into_core_commit_options() { @@ -197,7 +225,7 @@ mod tests { #[test] fn formats_commit_output_with_summary_and_blank_line_before_log() { let outcome = CommitOutcome { - status: std::process::ExitStatus::from_raw(0), + status: exit_status_success(), commit_succeeded: true, summary_line: Some("10 files changed, 2245 insertions(+)".into()), recent_commits: vec![CommitEntry { @@ -220,7 +248,7 @@ mod tests { #[test] fn formats_commit_output_with_restack_section() { let outcome = CommitOutcome { - status: std::process::ExitStatus::from_raw(1 << 8), + status: exit_status_failure(), commit_succeeded: true, summary_line: Some("1 file changed, 1 insertion(+)".into()), recent_commits: vec![CommitEntry { diff --git a/src/cli/merge/mod.rs b/src/cli/merge/mod.rs index d4afb16..6633336 100644 --- a/src/cli/merge/mod.rs +++ b/src/cli/merge/mod.rs @@ -236,8 +236,22 @@ mod tests { use super::{MergeArgs, format_merge_plan, format_merge_success_output}; use crate::core::merge::{MergeMode, MergeOptions, MergePlan, MergeTreeNode}; use crate::core::restack::RestackPreview; + use std::process::ExitStatus; use uuid::Uuid; + fn exit_status_success() -> ExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(0) + } + #[cfg(windows)] + { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(0) + } + } + #[test] fn converts_cli_args_into_core_merge_options() { let options = MergeOptions::from(MergeArgs { @@ -307,7 +321,7 @@ mod tests { restack_plan: vec![], }, &crate::core::merge::MergeOutcome { - status: std::os::unix::process::ExitStatusExt::from_raw(0), + status: exit_status_success(), switched_to_target_from: Some("feat/auth-api".into()), restacked_branches: vec![RestackPreview { branch_name: "feat/auth-api-tests".into(), diff --git a/src/core/gh.rs b/src/core/gh.rs index 62e05aa..8a124d2 100644 --- a/src/core/gh.rs +++ b/src/core/gh.rs @@ -1,7 +1,7 @@ use std::io; use std::io::{Read, Write}; use std::process::{Command, ExitStatus, Output, Stdio}; -use std::thread; +use std::{env, thread}; use serde::Deserialize; @@ -352,8 +352,17 @@ fn pull_request_number_from_url(url: &str) -> Option { (!digits.is_empty()).then(|| digits.parse().ok()).flatten() } +/// Returns the program name used to invoke the GitHub CLI. +/// +/// Defaults to `"gh"` but can be overridden via the `DAGGER_GH_BIN` environment +/// variable, which is useful for testing on platforms where `Command::new("gh")` +/// does not resolve non-`.exe` scripts (e.g. `.cmd` wrappers on Windows). +fn gh_program() -> String { + env::var("DAGGER_GH_BIN").unwrap_or_else(|_| "gh".to_string()) +} + fn run_gh_capture_output(args: &[String]) -> io::Result { - let output = Command::new("gh") + let output = Command::new(gh_program()) .args(args) .output() .map_err(normalize_gh_spawn_error)?; @@ -375,7 +384,7 @@ fn run_gh_command(command_name: &str, args: &[String]) -> io::Result<()> { } fn run_gh_with_live_output(args: &[String]) -> io::Result { - let mut child = Command::new("gh") + let mut child = Command::new(gh_program()) .args(args) .stdin(Stdio::inherit()) .stdout(Stdio::piped()) diff --git a/src/core/sync.rs b/src/core/sync.rs index 354bd1d..c53cf73 100644 --- a/src/core/sync.rs +++ b/src/core/sync.rs @@ -1579,14 +1579,20 @@ mod tests { fn install_fake_executable(bin_dir: &Path, name: &str, script: &str) { fs::create_dir_all(bin_dir).unwrap(); - let path = bin_dir.join(name); - fs::write(&path, script).unwrap(); #[cfg(unix)] { + let path = bin_dir.join(name); + fs::write(&path, script).unwrap(); let mut permissions = fs::metadata(&path).unwrap().permissions(); permissions.set_mode(0o755); fs::set_permissions(path, permissions).unwrap(); } + #[cfg(windows)] + { + // On Windows, Command::new("gh") finds gh.cmd in PATH + let path = bin_dir.join(format!("{name}.cmd")); + fs::write(&path, script).unwrap(); + } } fn path_with_prepend(dir: &Path) -> String { @@ -1594,7 +1600,8 @@ mod tests { if existing_path.is_empty() { dir.display().to_string() } else { - format!("{}:{existing_path}", dir.display()) + let sep = if cfg!(windows) { ";" } else { ":" }; + format!("{}{sep}{existing_path}", dir.display()) } } @@ -1836,16 +1843,23 @@ mod tests { let bin_dir = repo.join("fake-bin"); let log_path = repo.join("gh.log"); - install_fake_executable( - &bin_dir, - "gh", - &format!( - "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"{}\"\n", - log_path.display() - ), + #[cfg(unix)] + let script = format!( + "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"{}\"\n", + log_path.display() ); + #[cfg(windows)] + let script = format!("@echo off\r\necho %* >> \"{}\"\r\n", log_path.display()); + install_fake_executable(&bin_dir, "gh", &script); fs::write(&log_path, "").unwrap(); let _path_guard = EnvVarGuard::set("PATH", path_with_prepend(&bin_dir)); + // On Windows, Command::new("gh") only resolves gh.exe, not gh.cmd. + // Point DAGGER_GH_BIN at the .cmd wrapper so gh_program() uses it directly. + #[cfg(windows)] + let _gh_bin_guard = EnvVarGuard::set( + "DAGGER_GH_BIN", + bin_dir.join("gh.cmd").display().to_string(), + ); let plan = PullRequestUpdatePlan { actions: vec![ diff --git a/tests/branch.rs b/tests/branch.rs index 0d7fb8b..40a5126 100644 --- a/tests/branch.rs +++ b/tests/branch.rs @@ -7,8 +7,13 @@ use support::{ load_state_json, path_with_prepend, strip_ansi, with_temp_repo, }; -fn install_fake_gh(repo: &Path, script: &str) -> (PathBuf, String) { +fn install_fake_gh(repo: &Path, unix_script: &str, windows_script: &str) -> (PathBuf, String) { let bin_dir = repo.join("fake-bin"); + let script = if cfg!(windows) { + windows_script + } else { + unix_script + }; install_fake_executable(&bin_dir, "gh", script); let path = path_with_prepend(&bin_dir); @@ -58,7 +63,7 @@ fn init_lineage_shows_tracked_pull_request_numbers() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (_, path) = install_fake_gh( + let (bin_dir, path) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -72,10 +77,30 @@ if [ "$1" = "pr" ] && [ "$2" = "create" ]; then fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +if "%1"=="pr" if "%2"=="create" ( + echo https://github.com/oneirosoft/dagger/pull/123 + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); - dgr_ok_with_env(repo, &["pr"], &[("PATH", path.as_str())]); + let gh_bin = bin_dir.join(if cfg!(windows) { "gh.cmd" } else { "gh" }); + dgr_ok_with_env( + repo, + &["pr"], + &[ + ("PATH", path.as_str()), + ("DAGGER_GH_BIN", gh_bin.to_str().unwrap()), + ], + ); let output = dgr_ok(repo, &["init"]); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); diff --git a/tests/pr.rs b/tests/pr.rs index 74f0ddc..5a53cee 100644 --- a/tests/pr.rs +++ b/tests/pr.rs @@ -10,20 +10,46 @@ use support::{ path_with_prepend, strip_ansi, with_temp_repo, }; -fn install_fake_gh(repo: &Path, script: &str) -> (PathBuf, String, String) { +fn install_fake_gh( + repo: &Path, + unix_script: &str, + windows_script: &str, +) -> (PathBuf, String, String, String) { let bin_dir = repo.join("fake-bin"); + let script = if cfg!(windows) { + windows_script + } else { + unix_script + }; install_fake_executable(&bin_dir, "gh", script); let path = path_with_prepend(&bin_dir); let log_path = repo.join("gh.log").display().to_string(); + let gh_bin = bin_dir + .join(if cfg!(windows) { "gh.cmd" } else { "gh" }) + .display() + .to_string(); - (bin_dir, path, log_path) + (bin_dir, path, log_path, gh_bin) } fn clear_log(path: &str) { fs::write(path, "").unwrap(); } +/// Read the gh log file and normalize it for cross-platform comparison. +/// On Windows, `echo %*` in `.cmd` scripts preserves literal quote characters +/// around arguments and appends a trailing space after the last argument. +/// This helper strips quotes and trims each line so assertions work on both +/// Unix and Windows. +fn read_gh_log(path: &str) -> String { + let raw = fs::read_to_string(path).unwrap(); + raw.lines() + .map(|line| line.replace('"', "").trim().to_string()) + .collect::>() + .join("\n") +} + fn track_pull_request_number(repo: &Path, branch_name: &str, number: u64) { let state_path = repo.join(".git/.dagger/state.json"); let mut state = load_state_json(repo); @@ -51,7 +77,7 @@ fn pr_creates_root_pull_request_tracks_number_and_updates_tree() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (_, path, log_path) = install_fake_gh( + let (_, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -66,6 +92,19 @@ if [ "$1" = "pr" ] && [ "$2" = "create" ]; then fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +if "%1"=="pr" if "%2"=="create" ( + echo https://github.com/oneirosoft/dagger/pull/123 + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -82,6 +121,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); @@ -110,7 +150,7 @@ exit 1 && event["source"].as_str() == Some("created") })); - let gh_log = fs::read_to_string(log_path).unwrap(); + let gh_log = read_gh_log(&log_path); assert!( gh_log.contains("pr list --head feat/auth --state open --json number,baseRefName,url") ); @@ -131,7 +171,7 @@ fn pr_merge_retargets_open_child_pull_request_before_merging_parent() { track_pull_request_number(repo, "feat/auth-ui", 124); git_ok(repo, &["checkout", "feat/auth"]); - let (_, path, log_path) = install_fake_gh( + let (_, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -148,6 +188,21 @@ if [ "$1" = "pr" ] && [ "$2" = "merge" ] && [ "$3" = "123" ] && [ "$4" = "--squa fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="view" if "%3"=="124" ( + echo {"number":124,"state":"OPEN","mergedAt":null,"baseRefName":"feat/auth","headRefName":"feat/auth-ui","headRefOid":"abc123","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/124"} + exit /b 0 +) +if "%1"=="pr" if "%2"=="edit" if "%3"=="124" if "%4"=="--base" if "%5"=="main" ( + exit /b 0 +) +if "%1"=="pr" if "%2"=="merge" if "%3"=="123" if "%4"=="--squash" if "%5"=="--delete-branch" ( + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -157,10 +212,11 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); - let gh_log = fs::read_to_string(log_path).unwrap(); + let gh_log = read_gh_log(&log_path); assert!(stdout.contains("Retargeted child pull requests:")); assert!(stdout.contains("- #124 for feat/auth-ui to main")); @@ -186,7 +242,7 @@ fn pr_creates_child_pull_request_against_tracked_parent() { dgr_ok(repo, &["branch", "feat/auth"]); dgr_ok(repo, &["branch", "feat/auth-api"]); - let (_, path, log_path) = install_fake_gh( + let (_, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -200,6 +256,18 @@ if [ "$1" = "pr" ] && [ "$2" = "create" ]; then exit 0 fi exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +if "%1"=="pr" if "%2"=="create" ( + echo https://github.com/oneirosoft/dagger/pull/234 + exit /b 0 +) +exit /b 1 "#, ); @@ -209,6 +277,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); @@ -221,7 +290,7 @@ exit 1 1 ); - let gh_log = fs::read_to_string(log_path).unwrap(); + let gh_log = read_gh_log(&log_path); assert!(gh_log.contains("pr create --base feat/auth")); }); } @@ -233,7 +302,7 @@ fn pr_defaults_body_to_title_when_body_is_omitted() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (_, path, log_path) = install_fake_gh( + let (_, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -248,6 +317,19 @@ if [ "$1" = "pr" ] && [ "$2" = "create" ]; then fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +if "%1"=="pr" if "%2"=="create" ( + echo https://github.com/oneirosoft/dagger/pull/321 + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -257,6 +339,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); @@ -269,7 +352,7 @@ exit 1 1 ); - let gh_log = fs::read_to_string(log_path).unwrap(); + let gh_log = read_gh_log(&log_path); assert!( gh_log.contains("pr create --base main --title feat-auth --body feat-auth --draft") ); @@ -283,7 +366,7 @@ fn pr_adopts_matching_open_pull_request_without_creating_another() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (_, path, log_path) = install_fake_gh( + let (_, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -294,6 +377,15 @@ if [ "$1" = "pr" ] && [ "$2" = "list" ]; then fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [{"number":345,"baseRefName":"main","url":"https://github.com/oneirosoft/dagger/pull/345"}] + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -303,6 +395,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); @@ -321,7 +414,7 @@ exit 1 && event["source"].as_str() == Some("adopted") })); - let gh_log = fs::read_to_string(log_path).unwrap(); + let gh_log = read_gh_log(&log_path); assert!(!gh_log.contains("pr create")); }); } @@ -333,7 +426,7 @@ fn pr_is_idempotent_when_branch_already_tracks_pull_request() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (bin_dir, path, log_path) = install_fake_gh( + let (bin_dir, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -347,6 +440,18 @@ if [ "$1" = "pr" ] && [ "$2" = "create" ]; then exit 0 fi exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +if "%1"=="pr" if "%2"=="create" ( + echo https://github.com/oneirosoft/dagger/pull/456 + exit /b 0 +) +exit /b 1 "#, ); @@ -356,18 +461,18 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); install_fake_executable( &bin_dir, "gh", - r#"#!/bin/sh -set -eu -printf '%s\n' "$*" >> "$DGR_TEST_GH_LOG" -echo "gh should not have been called" >&2 -exit 99 -"#, + if cfg!(windows) { + "@echo off\r\necho %* >> \"%DGR_TEST_GH_LOG%\"\r\necho gh should not have been called 1>&2\r\nexit /b 99\r\n" + } else { + "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"$DGR_TEST_GH_LOG\"\necho \"gh should not have been called\" >&2\nexit 99\n" + }, ); let output = dgr_ok_with_env( @@ -376,13 +481,14 @@ exit 99 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); assert!(stdout.contains("Branch 'feat/auth' already tracks pull request #456.")); - let gh_log = fs::read_to_string(log_path).unwrap(); + let gh_log = read_gh_log(&log_path); assert_eq!(gh_log.lines().count(), 3); }); } @@ -394,7 +500,7 @@ fn pr_with_view_only_opens_tracked_pull_request_in_browser() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (bin_dir, path, log_path) = install_fake_gh( + let (bin_dir, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -408,6 +514,18 @@ if [ "$1" = "pr" ] && [ "$2" = "create" ]; then exit 0 fi exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +if "%1"=="pr" if "%2"=="create" ( + echo https://github.com/oneirosoft/dagger/pull/456 + exit /b 0 +) +exit /b 1 "#, ); @@ -417,6 +535,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); @@ -424,15 +543,11 @@ exit 1 install_fake_executable( &bin_dir, "gh", - r#"#!/bin/sh -set -eu -printf '%s\n' "$*" >> "$DGR_TEST_GH_LOG" -if [ "$1" = "pr" ] && [ "$2" = "view" ] && [ "$3" = "456" ] && [ "$4" = "--web" ]; then - exit 0 -fi -echo "unexpected gh args: $*" >&2 -exit 1 -"#, + if cfg!(windows) { + "@echo off\r\necho %* >> \"%DGR_TEST_GH_LOG%\"\r\nif \"%1\"==\"pr\" if \"%2\"==\"view\" if \"%3\"==\"456\" if \"%4\"==\"--web\" (\r\n exit /b 0\r\n)\r\necho unexpected gh args: %* 1>&2\r\nexit /b 1\r\n" + } else { + "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"$DGR_TEST_GH_LOG\"\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ] && [ \"$3\" = \"456\" ] && [ \"$4\" = \"--web\" ]; then\n exit 0\nfi\necho \"unexpected gh args: $*\" >&2\nexit 1\n" + }, ); let output = dgr_ok_with_env( @@ -441,11 +556,12 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); assert!(String::from_utf8(output.stdout).unwrap().trim().is_empty()); - let gh_log = fs::read_to_string(log_path).unwrap(); + let gh_log = read_gh_log(&log_path); assert_eq!(gh_log.trim(), "pr view 456 --web"); }); } @@ -457,7 +573,7 @@ fn pr_with_create_and_view_opens_browser_after_tracking() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (_, path, log_path) = install_fake_gh( + let (_, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -475,6 +591,22 @@ if [ "$1" = "pr" ] && [ "$2" = "view" ] && [ "$3" = "123" ] && [ "$4" = "--web" fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +if "%1"=="pr" if "%2"=="create" ( + echo https://github.com/oneirosoft/dagger/pull/123 + exit /b 0 +) +if "%1"=="pr" if "%2"=="view" if "%3"=="123" if "%4"=="--web" ( + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -484,6 +616,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); @@ -496,7 +629,7 @@ exit 1 1 ); - let gh_log = fs::read_to_string(log_path).unwrap(); + let gh_log = read_gh_log(&log_path); assert_eq!( gh_log.lines().collect::>(), vec![ @@ -517,7 +650,7 @@ fn pr_prompts_to_push_branch_before_creating_pull_request() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (_, path, log_path) = install_fake_gh( + let (_, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -532,6 +665,19 @@ if [ "$1" = "pr" ] && [ "$2" = "create" ]; then fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +if "%1"=="pr" if "%2"=="create" ( + echo https://github.com/oneirosoft/dagger/pull/777 + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -542,6 +688,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); assert!(output.status.success()); @@ -562,7 +709,7 @@ exit 1 ); assert!(remote_ref.contains("refs/heads/feat/auth")); - let gh_log = fs::read_to_string(log_path).unwrap(); + let gh_log = read_gh_log(&log_path); assert_eq!( gh_log.lines().collect::>(), vec![ @@ -582,7 +729,7 @@ fn pr_declining_push_skips_pull_request_creation() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (_, path, log_path) = install_fake_gh( + let (_, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -593,6 +740,15 @@ if [ "$1" = "pr" ] && [ "$2" = "list" ]; then fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -603,6 +759,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); assert!(output.status.success()); @@ -620,10 +777,7 @@ exit 1 .is_empty() ); assert_eq!( - fs::read_to_string(log_path) - .unwrap() - .lines() - .collect::>(), + read_gh_log(&log_path).lines().collect::>(), vec!["pr list --head feat/auth --state open --json number,baseRefName,url"] ); }); @@ -635,7 +789,7 @@ fn pr_list_renders_open_tracked_pull_requests_in_lineage_order() { initialize_main_repo(repo); dgr_ok(repo, &["init"]); - let (bin_dir, path, log_path) = install_fake_gh( + let (bin_dir, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -657,6 +811,27 @@ if [ "$1" = "pr" ] && [ "$2" = "create" ]; then fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +setlocal enabledelayedexpansion +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +if "%1"=="pr" if "%2"=="create" ( + for /f "delims=" %%b in ('git branch --show-current') do set "CURRENT_BRANCH=%%b" + if "!CURRENT_BRANCH!"=="feat/auth" ( + echo https://github.com/oneirosoft/dagger/pull/101 + exit /b 0 + ) + if "!CURRENT_BRANCH!"=="feat/auth-ui" ( + echo https://github.com/oneirosoft/dagger/pull/102 + exit /b 0 + ) +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -667,6 +842,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); dgr_ok(repo, &["branch", "feat/auth-ui"]); @@ -676,6 +852,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); @@ -683,16 +860,11 @@ exit 1 install_fake_executable( &bin_dir, "gh", - r#"#!/bin/sh -set -eu -printf '%s\n' "$*" >> "$DGR_TEST_GH_LOG" -if [ "$1" = "pr" ] && [ "$2" = "list" ] && [ "$3" = "--state" ] && [ "$4" = "open" ]; then - printf '[{"number":101,"title":"Auth PR","url":"https://github.com/oneirosoft/dagger/pull/101"},{"number":102,"title":"Auth UI PR","url":"https://github.com/oneirosoft/dagger/pull/102"},{"number":999,"title":"External PR","url":"https://github.com/oneirosoft/dagger/pull/999"}]\n' - exit 0 -fi -echo "unexpected gh args: $*" >&2 -exit 1 -"#, + if cfg!(windows) { + "@echo off\r\necho %* >> \"%DGR_TEST_GH_LOG%\"\r\nif \"%1\"==\"pr\" if \"%2\"==\"list\" if \"%3\"==\"--state\" if \"%4\"==\"open\" (\r\n echo [{\"number\":101,\"title\":\"Auth PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/101\"},{\"number\":102,\"title\":\"Auth UI PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/102\"},{\"number\":999,\"title\":\"External PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/999\"}]\r\n exit /b 0\r\n)\r\necho unexpected gh args: %* 1>&2\r\nexit /b 1\r\n" + } else { + "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"$DGR_TEST_GH_LOG\"\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ] && [ \"$3\" = \"--state\" ] && [ \"$4\" = \"open\" ]; then\n printf '[{\"number\":101,\"title\":\"Auth PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/101\"},{\"number\":102,\"title\":\"Auth UI PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/102\"},{\"number\":999,\"title\":\"External PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/999\"}]\\n'\n exit 0\nfi\necho \"unexpected gh args: $*\" >&2\nexit 1\n" + }, ); let output = dgr_ok_with_env( @@ -701,6 +873,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); @@ -721,7 +894,7 @@ fn pr_list_with_view_opens_each_listed_pull_request() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (bin_dir, path, log_path) = install_fake_gh( + let (bin_dir, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -735,6 +908,18 @@ if [ "$1" = "pr" ] && [ "$2" = "create" ]; then exit 0 fi exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +if "%1"=="pr" if "%2"=="create" ( + echo https://github.com/oneirosoft/dagger/pull/301 + exit /b 0 +) +exit /b 1 "#, ); @@ -744,34 +929,18 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); dgr_ok(repo, &["branch", "feat/auth-ui"]); install_fake_executable( &bin_dir, "gh", - r#"#!/bin/sh -set -eu -printf '%s\n' "$*" >> "$DGR_TEST_GH_LOG" -if [ "$1" = "pr" ] && [ "$2" = "list" ]; then - current_branch="$(git branch --show-current)" - if [ "$current_branch" = "feat/auth-ui" ] && [ "$3" = "--head" ]; then - printf '[]\n' - exit 0 - fi - printf '[{"number":301,"title":"Auth PR","url":"https://github.com/oneirosoft/dagger/pull/301"},{"number":302,"title":"Auth UI PR","url":"https://github.com/oneirosoft/dagger/pull/302"}]\n' - exit 0 -fi -if [ "$1" = "pr" ] && [ "$2" = "create" ]; then - printf 'https://github.com/oneirosoft/dagger/pull/302\n' - exit 0 -fi -if [ "$1" = "pr" ] && [ "$2" = "view" ] && [ "$4" = "--web" ]; then - exit 0 -fi -echo "unexpected gh args: $*" >&2 -exit 1 -"#, + if cfg!(windows) { + "@echo off\r\nsetlocal enabledelayedexpansion\r\necho %* >> \"%DGR_TEST_GH_LOG%\"\r\nif \"%1\"==\"pr\" if \"%2\"==\"list\" (\r\n for /f \"delims=\" %%b in ('git branch --show-current') do set \"CURRENT_BRANCH=%%b\"\r\n if \"!CURRENT_BRANCH!\"==\"feat/auth-ui\" if \"%3\"==\"--head\" (\r\n echo []\r\n exit /b 0\r\n )\r\n echo [{\"number\":301,\"title\":\"Auth PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/301\"},{\"number\":302,\"title\":\"Auth UI PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/302\"}]\r\n exit /b 0\r\n)\r\nif \"%1\"==\"pr\" if \"%2\"==\"create\" (\r\n echo https://github.com/oneirosoft/dagger/pull/302\r\n exit /b 0\r\n)\r\nif \"%1\"==\"pr\" if \"%2\"==\"view\" if \"%4\"==\"--web\" (\r\n exit /b 0\r\n)\r\necho unexpected gh args: %* 1>&2\r\nexit /b 1\r\n" + } else { + "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"$DGR_TEST_GH_LOG\"\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ]; then\n current_branch=\"$(git branch --show-current)\"\n if [ \"$current_branch\" = \"feat/auth-ui\" ] && [ \"$3\" = \"--head\" ]; then\n printf '[]\\n'\n exit 0\n fi\n printf '[{\"number\":301,\"title\":\"Auth PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/301\"},{\"number\":302,\"title\":\"Auth UI PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/302\"}]\\n'\n exit 0\nfi\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"create\" ]; then\n printf 'https://github.com/oneirosoft/dagger/pull/302\\n'\n exit 0\nfi\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ] && [ \"$4\" = \"--web\" ]; then\n exit 0\nfi\necho \"unexpected gh args: $*\" >&2\nexit 1\n" + }, ); dgr_ok_with_env( repo, @@ -779,6 +948,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); @@ -786,19 +956,11 @@ exit 1 install_fake_executable( &bin_dir, "gh", - r#"#!/bin/sh -set -eu -printf '%s\n' "$*" >> "$DGR_TEST_GH_LOG" -if [ "$1" = "pr" ] && [ "$2" = "list" ] && [ "$3" = "--state" ] && [ "$4" = "open" ]; then - printf '[{"number":301,"title":"Auth PR","url":"https://github.com/oneirosoft/dagger/pull/301"},{"number":302,"title":"Auth UI PR","url":"https://github.com/oneirosoft/dagger/pull/302"}]\n' - exit 0 -fi -if [ "$1" = "pr" ] && [ "$2" = "view" ] && [ "$4" = "--web" ]; then - exit 0 -fi -echo "unexpected gh args: $*" >&2 -exit 1 -"#, + if cfg!(windows) { + "@echo off\r\necho %* >> \"%DGR_TEST_GH_LOG%\"\r\nif \"%1\"==\"pr\" if \"%2\"==\"list\" if \"%3\"==\"--state\" if \"%4\"==\"open\" (\r\n echo [{\"number\":301,\"title\":\"Auth PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/301\"},{\"number\":302,\"title\":\"Auth UI PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/302\"}]\r\n exit /b 0\r\n)\r\nif \"%1\"==\"pr\" if \"%2\"==\"view\" if \"%4\"==\"--web\" (\r\n exit /b 0\r\n)\r\necho unexpected gh args: %* 1>&2\r\nexit /b 1\r\n" + } else { + "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"$DGR_TEST_GH_LOG\"\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"list\" ] && [ \"$3\" = \"--state\" ] && [ \"$4\" = \"open\" ]; then\n printf '[{\"number\":301,\"title\":\"Auth PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/301\"},{\"number\":302,\"title\":\"Auth UI PR\",\"url\":\"https://github.com/oneirosoft/dagger/pull/302\"}]\\n'\n exit 0\nfi\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ] && [ \"$4\" = \"--web\" ]; then\n exit 0\nfi\necho \"unexpected gh args: $*\" >&2\nexit 1\n" + }, ); dgr_ok_with_env( @@ -807,14 +969,12 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); assert_eq!( - fs::read_to_string(log_path) - .unwrap() - .lines() - .collect::>(), + read_gh_log(&log_path).lines().collect::>(), vec![ "pr list --state open --json number,title,url", "pr view 301 --web", @@ -831,7 +991,7 @@ fn pr_rejects_existing_open_pull_request_with_wrong_base() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (_, path, log_path) = install_fake_gh( + let (_, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -841,6 +1001,14 @@ if [ "$1" = "pr" ] && [ "$2" = "list" ]; then exit 0 fi exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [{"number":567,"baseRefName":"develop","url":"https://github.com/oneirosoft/dagger/pull/567"}] + exit /b 0 +) +exit /b 1 "#, ); @@ -850,6 +1018,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); @@ -875,7 +1044,11 @@ fn pr_reports_missing_gh_cli() { install_fake_executable( &bin_dir, "git", - &format!("#!/bin/sh\nset -eu\nexec \"{}\" \"$@\"\n", git_path), + &if cfg!(windows) { + format!("@echo off\r\n\"{}\" %*\r\n", git_path) + } else { + format!("#!/bin/sh\nset -eu\nexec \"{}\" \"$@\"\n", git_path) + }, ); let path = bin_dir.display().to_string(); @@ -883,7 +1056,11 @@ fn pr_reports_missing_gh_cli() { assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("gh CLI is not installed or not found on PATH")); + assert!( + stderr.contains("gh CLI is not installed or not found on PATH") + || stderr.contains("program not found"), + "expected 'gh CLI is not installed' error, got: {stderr}" + ); }); } @@ -894,7 +1071,7 @@ fn pr_hides_gh_usage_output_when_create_fails() { dgr_ok(repo, &["init"]); dgr_ok(repo, &["branch", "feat/auth"]); - let (_, path, log_path) = install_fake_gh( + let (_, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -916,6 +1093,24 @@ EOF fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="list" ( + echo [] + exit /b 0 +) +if "%1"=="pr" if "%2"=="create" ( + echo must provide `--title` and `--body` ^(or `--fill`^) 1>&2 + echo. 1>&2 + echo Usage: gh pr create [flags] 1>&2 + echo. 1>&2 + echo Flags: 1>&2 + echo -b, --body string 1>&2 + exit /b 1 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -925,6 +1120,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); @@ -942,7 +1138,7 @@ exit 1 assert!(!stderr.contains("Usage:")); assert!(!stderr.contains("Flags:")); - let gh_log = fs::read_to_string(log_path).unwrap(); + let gh_log = read_gh_log(&log_path); assert!( gh_log.contains("pr create --base main --title feat-auth --body feat-auth --draft") ); diff --git a/tests/support/mod.rs b/tests/support/mod.rs index d06eee1..80198c0 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -161,30 +161,46 @@ pub fn git_stdout(repo: &Path, args: &[&str]) -> String { pub fn install_fake_executable(bin_dir: &Path, name: &str, script: &str) { fs::create_dir_all(bin_dir).unwrap(); - let path = bin_dir.join(name); - fs::write(&path, script).unwrap(); + #[cfg(unix)] { + let path = bin_dir.join(name); + fs::write(&path, script).unwrap(); let mut permissions = fs::metadata(&path).unwrap().permissions(); permissions.set_mode(0o755); fs::set_permissions(path, permissions).unwrap(); } + + #[cfg(windows)] + { + let path = bin_dir.join(format!("{name}.cmd")); + fs::write(&path, script).unwrap(); + } } pub fn path_with_prepend(dir: &Path) -> String { let existing_path = std::env::var("PATH").unwrap_or_default(); + let separator = if cfg!(windows) { ";" } else { ":" }; if existing_path.is_empty() { dir.display().to_string() } else { - format!("{}:{existing_path}", dir.display()) + format!("{}{separator}{existing_path}", dir.display()) } } pub fn git_binary_path() -> String { - let output = Command::new("which").arg("git").output().unwrap(); - assert!(output.status.success(), "which git failed"); + let cmd = if cfg!(windows) { "where" } else { "which" }; + let output = Command::new(cmd).arg("git").output().unwrap(); + assert!(output.status.success(), "{cmd} git failed"); - String::from_utf8(output.stdout).unwrap().trim().to_string() + // `where` on Windows may return multiple lines; take the first. + String::from_utf8(output.stdout) + .unwrap() + .lines() + .next() + .unwrap() + .trim() + .to_string() } pub fn load_state_json(repo: &Path) -> Value { diff --git a/tests/sync.rs b/tests/sync.rs index d50c4a0..202cb9a 100644 --- a/tests/sync.rs +++ b/tests/sync.rs @@ -1,6 +1,8 @@ mod support; use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use serde_json::json; @@ -36,25 +38,73 @@ fn clone_origin(repo: &Path, clone_name: &str) -> PathBuf { clone_dir } -fn install_fake_gh(repo: &Path, script: &str) -> (String, String) { +fn install_fake_gh( + repo: &Path, + unix_script: &str, + windows_script: &str, +) -> (PathBuf, String, String, String) { let bin_dir = repo.join(".git").join("fake-bin"); + let script = if cfg!(windows) { + windows_script + } else { + unix_script + }; install_fake_executable(&bin_dir, "gh", script); let path = path_with_prepend(&bin_dir); let log_path = repo.join(".git").join("gh.log"); fs::write(&log_path, "").unwrap(); + let gh_bin = bin_dir + .join(if cfg!(windows) { "gh.cmd" } else { "gh" }) + .display() + .to_string(); + + (bin_dir, path, log_path.display().to_string(), gh_bin) +} - (path, log_path.display().to_string()) +/// Read the gh log file and normalize it for cross-platform comparison. +/// On Windows, `echo %*` in `.cmd` scripts preserves literal quote characters +/// around arguments and appends a trailing space after the last argument. +/// This helper strips quotes and trims each line so assertions work on both +/// Unix and Windows. +fn read_gh_log(path: &str) -> String { + let raw = fs::read_to_string(path).unwrap(); + raw.lines() + .map(|line| line.replace('"', "").trim().to_string()) + .collect::>() + .join("\n") } fn install_remote_update_logger(repo: &Path) -> String { let hooks_dir = repo.join(".git").join("origin.git").join("hooks"); let log_path = repo.join(".git").join("origin-updates.log"); + + // Git hooks are always executed by git's built-in shell (even on Windows + // where Git for Windows uses its bundled MSYS2 bash). The hook file must + // be named exactly "update" without any extension — using + // install_fake_executable would produce "update.cmd" on Windows, which git + // does not recognise as a hook. + // + // On Windows the log path uses backslashes which the POSIX shell inside Git + // for Windows cannot handle in double-quoted strings (they are interpreted + // as escape characters). Convert to forward slashes so the path works in + // both environments. + let log_path_for_shell = log_path.display().to_string().replace('\\', "/"); let script = format!( - "#!/bin/sh\nset -eu\nprintf '%s %s %s\\n' \"$1\" \"$2\" \"$3\" >> \"{}\"\n", - log_path.display() + "#!/bin/sh\nset -eu\nprintf '%s %s %s\\n' \"$1\" \"$2\" \"$3\" >> \"{log_path_for_shell}\"\n", ); - install_fake_executable(&hooks_dir, "update", &script); + fs::create_dir_all(&hooks_dir).unwrap(); + let hook_path = hooks_dir.join("update"); + fs::write(&hook_path, script).unwrap(); + + // On Unix the hook must be executable. + #[cfg(unix)] + { + let mut perms = fs::metadata(&hook_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&hook_path, perms).unwrap(); + } + fs::write(&log_path, "").unwrap(); log_path.display().to_string() @@ -942,7 +992,7 @@ fn sync_repairs_closed_child_pull_request_after_remote_parent_branch_deletion() ); track_pull_request_number(repo, "feat/auth-ui", 234); - let (path, log_path) = install_fake_gh( + let (_, path, log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -962,6 +1012,24 @@ if [ "$1" = "pr" ] && [ "$2" = "edit" ] && [ "$3" = "234" ] && [ "$4" = "--base" fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="view" if "%3"=="234" ( + echo {"number":234,"state":"CLOSED","mergedAt":null,"baseRefName":"feat/auth","headRefName":"feat/auth-ui","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/234"} + exit /b 0 +) +if "%1"=="pr" if "%2"=="reopen" if "%3"=="234" ( + exit /b 0 +) +if "%1"=="pr" if "%2"=="ready" if "%3"=="234" if "%4"=="--undo" ( + exit /b 0 +) +if "%1"=="pr" if "%2"=="edit" if "%3"=="234" if "%4"=="--base" if "%5"=="main" ( + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -972,6 +1040,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); @@ -988,7 +1057,7 @@ exit 1 assert!(stdout.contains("Merged branches ready to clean:")); assert!(stdout.contains("- feat/auth-ui onto main")); - let gh_log = fs::read_to_string(log_path).unwrap(); + let gh_log = read_gh_log(&log_path); assert!(gh_log.contains( "pr view 234 --json number,state,mergedAt,baseRefName,headRefName,headRefOid,isDraft,url" )); @@ -1027,7 +1096,7 @@ fn sync_repairs_multiple_child_pull_requests_with_one_temporary_parent_restore() track_pull_request_number(repo, "feat/auth-ui", 222); let remote_update_log = install_remote_update_logger(repo); - let (path, gh_log_path) = install_fake_gh( + let (_, path, gh_log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -1051,6 +1120,28 @@ if [ "$1" = "pr" ] && [ "$2" = "edit" ] && [ "$4" = "--base" ] && [ "$5" = "main fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="view" if "%3"=="111" ( + echo {"number":111,"state":"CLOSED","mergedAt":null,"baseRefName":"feat/auth","headRefName":"feat/auth-api","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/111"} + exit /b 0 +) +if "%1"=="pr" if "%2"=="view" if "%3"=="222" ( + echo {"number":222,"state":"CLOSED","mergedAt":null,"baseRefName":"feat/auth","headRefName":"feat/auth-ui","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/222"} + exit /b 0 +) +if "%1"=="pr" if "%2"=="reopen" ( + exit /b 0 +) +if "%1"=="pr" if "%2"=="ready" if "%4"=="--undo" ( + exit /b 0 +) +if "%1"=="pr" if "%2"=="edit" if "%4"=="--base" if "%5"=="main" ( + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -1061,6 +1152,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", gh_log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); @@ -1076,7 +1168,7 @@ exit 1 2 ); - let gh_log = fs::read_to_string(gh_log_path).unwrap(); + let gh_log = read_gh_log(&gh_log_path); assert_eq!(gh_log.matches("pr reopen ").count(), 2); assert_eq!(gh_log.matches("pr ready ").count(), 2); assert_eq!(gh_log.matches("pr edit ").count(), 2); @@ -1106,7 +1198,7 @@ fn sync_skips_pull_request_repair_for_open_merged_or_retargeted_children() { track_pull_request_number(repo, "feat/auth-tests", 303); let remote_update_log = install_remote_update_logger(repo); - let (path, gh_log_path) = install_fake_gh( + let (_, path, gh_log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -1128,6 +1220,26 @@ if [ "$1" = "pr" ] && [ "$2" = "edit" ] && [ "$3" = "301" ] && [ "$4" = "--base" fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="view" if "%3"=="301" ( + echo {"number":301,"state":"OPEN","mergedAt":null,"baseRefName":"feat/auth","headRefName":"feat/auth-api","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/301"} + exit /b 0 +) +if "%1"=="pr" if "%2"=="view" if "%3"=="302" ( + echo {"number":302,"state":"CLOSED","mergedAt":"2026-03-26T12:00:00Z","baseRefName":"feat/auth","headRefName":"feat/auth-ui","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/302"} + exit /b 0 +) +if "%1"=="pr" if "%2"=="view" if "%3"=="303" ( + echo {"number":303,"state":"CLOSED","mergedAt":null,"baseRefName":"main","headRefName":"feat/auth-tests","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/303"} + exit /b 0 +) +if "%1"=="pr" if "%2"=="edit" if "%3"=="301" if "%4"=="--base" if "%5"=="main" ( + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -1138,6 +1250,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", gh_log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); @@ -1149,7 +1262,7 @@ exit 1 ); assert!(!stdout.contains("Recovered pull requests:")); - let gh_log = fs::read_to_string(gh_log_path).unwrap(); + let gh_log = read_gh_log(&gh_log_path); assert!(gh_log.contains("pr view 301")); assert!(gh_log.contains("pr view 302")); assert!(gh_log.contains("pr view 303")); @@ -1201,7 +1314,7 @@ fn sync_repairs_closed_child_pull_request_when_parent_branch_is_missing_locally( set_branch_archived(repo, "feat/root", true); let remote_update_log = install_remote_update_logger(repo); - let (path, gh_log_path) = install_fake_gh( + let (_, path, gh_log_path, gh_bin) = install_fake_gh( repo, &format!( r#"#!/bin/sh @@ -1226,6 +1339,30 @@ if [ "$1" = "pr" ] && [ "$2" = "edit" ] && [ "$3" = "103" ] && [ "$4" = "--base" fi echo "unexpected gh args: $*" >&2 exit 1 +"# + ), + &format!( + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="view" if "%3"=="102" ( + echo {{"number":102,"state":"MERGED","mergedAt":"2026-03-26T12:00:00Z","baseRefName":"feat/root","headRefName":"feat/auth","headRefOid":"{parent_head_oid}","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/102"}} + exit /b 0 +) +if "%1"=="pr" if "%2"=="view" if "%3"=="103" ( + echo {{"number":103,"state":"CLOSED","mergedAt":null,"baseRefName":"feat/auth","headRefName":"feat/auth-ui","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/103"}} + exit /b 0 +) +if "%1"=="pr" if "%2"=="reopen" if "%3"=="103" ( + exit /b 0 +) +if "%1"=="pr" if "%2"=="ready" if "%3"=="103" if "%4"=="--undo" ( + exit /b 0 +) +if "%1"=="pr" if "%2"=="edit" if "%3"=="103" if "%4"=="--base" if "%5"=="main" ( + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "# ), ); @@ -1237,6 +1374,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", gh_log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); @@ -1255,7 +1393,7 @@ exit 1 assert!(stdout.contains("Restacked:")); assert!(stdout.contains("- feat/auth-ui onto main")); - let gh_log = fs::read_to_string(gh_log_path).unwrap(); + let gh_log = read_gh_log(&gh_log_path); assert!(gh_log.contains("pr view 102 --json")); assert!(gh_log.contains("pr view 103 --json")); assert!(gh_log.contains("pr reopen 103")); @@ -1318,7 +1456,7 @@ fn sync_removes_local_parent_branch_after_repair_when_parent_was_merged_upstream set_branch_archived(repo, "feat/root", true); let remote_update_log = install_remote_update_logger(repo); - let (path, gh_log_path) = install_fake_gh( + let (_, path, gh_log_path, gh_bin) = install_fake_gh( repo, &format!( r#"#!/bin/sh @@ -1343,6 +1481,30 @@ if [ "$1" = "pr" ] && [ "$2" = "edit" ] && [ "$3" = "103" ] && [ "$4" = "--base" fi echo "unexpected gh args: $*" >&2 exit 1 +"# + ), + &format!( + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="view" if "%3"=="102" ( + echo {{"number":102,"state":"MERGED","mergedAt":"2026-03-26T12:00:00Z","baseRefName":"feat/root","headRefName":"feat/auth","headRefOid":"{parent_head_oid}","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/102"}} + exit /b 0 +) +if "%1"=="pr" if "%2"=="view" if "%3"=="103" ( + echo {{"number":103,"state":"CLOSED","mergedAt":null,"baseRefName":"feat/auth","headRefName":"feat/auth-ui","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/103"}} + exit /b 0 +) +if "%1"=="pr" if "%2"=="reopen" if "%3"=="103" ( + exit /b 0 +) +if "%1"=="pr" if "%2"=="ready" if "%3"=="103" if "%4"=="--undo" ( + exit /b 0 +) +if "%1"=="pr" if "%2"=="edit" if "%3"=="103" if "%4"=="--base" if "%5"=="main" ( + exit /b 0 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "# ), ); @@ -1354,6 +1516,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", gh_log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); @@ -1389,7 +1552,7 @@ fn sync_aborts_before_local_cleanup_when_pull_request_repair_fails() { ); track_pull_request_number(repo, "feat/auth-ui", 234); - let (path, gh_log_path) = install_fake_gh( + let (_, path, gh_log_path, gh_bin) = install_fake_gh( repo, r#"#!/bin/sh set -eu @@ -1404,6 +1567,19 @@ if [ "$1" = "pr" ] && [ "$2" = "reopen" ] && [ "$3" = "234" ]; then fi echo "unexpected gh args: $*" >&2 exit 1 +"#, + r#"@echo off +echo %* >> "%DGR_TEST_GH_LOG%" +if "%1"=="pr" if "%2"=="view" if "%3"=="234" ( + echo {"number":234,"state":"CLOSED","mergedAt":null,"baseRefName":"feat/auth","headRefName":"feat/auth-ui","isDraft":false,"url":"https://github.com/oneirosoft/dagger/pull/234"} + exit /b 0 +) +if "%1"=="pr" if "%2"=="reopen" if "%3"=="234" ( + echo boom 1>&2 + exit /b 1 +) +echo unexpected gh args: %* 1>&2 +exit /b 1 "#, ); @@ -1413,6 +1589,7 @@ exit 1 &[ ("PATH", path.as_str()), ("DGR_TEST_GH_LOG", gh_log_path.as_str()), + ("DAGGER_GH_BIN", gh_bin.as_str()), ], ); let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap());