diff --git a/app/release/burrito_patches.exs b/app/release/burrito_patches.exs index 8732fe5..6ed371d 100644 --- a/app/release/burrito_patches.exs +++ b/app/release/burrito_patches.exs @@ -67,25 +67,44 @@ defmodule LinearCli.Release.BurritoPatches do @doc false def patch_source!(source) when is_binary(source) do - cond do - contains_both?(source, @tty_aware_proxy, @conditional_error_join) -> - source - - contains_both?(source, @always_proxy, @unconditional_error_join) -> - source - |> String.replace(@always_proxy, @tty_aware_proxy) - |> String.replace(@unconditional_error_join, @conditional_error_join) - - true -> - raise """ - Burrito's stdout launcher no longer matches the expected source. Refusing to - build without checking whether the terminal inheritance fix is still needed. - This temporary patch tracks #{@upstream_pr}. - """ - end + newline = newline_style(source) + normalized_source = normalize_newlines(source) + always_proxy = normalize_newlines(@always_proxy) + tty_aware_proxy = normalize_newlines(@tty_aware_proxy) + unconditional_error_join = normalize_newlines(@unconditional_error_join) + conditional_error_join = normalize_newlines(@conditional_error_join) + + patched = + cond do + contains_both?(normalized_source, tty_aware_proxy, conditional_error_join) -> + normalized_source + + contains_both?(normalized_source, always_proxy, unconditional_error_join) -> + normalized_source + |> String.replace(always_proxy, tty_aware_proxy) + |> String.replace(unconditional_error_join, conditional_error_join) + + true -> + raise """ + Burrito's stdout launcher no longer matches the expected source. Refusing to + build without checking whether the terminal inheritance fix is still needed. + This temporary patch tracks #{@upstream_pr}. + """ + end + + restore_newlines(patched, newline) end defp contains_both?(source, first, second) do String.contains?(source, first) and String.contains?(source, second) end + + defp newline_style(source) do + if String.contains?(source, "\r\n"), do: :crlf, else: :lf + end + + defp normalize_newlines(source), do: String.replace(source, "\r\n", "\n") + + defp restore_newlines(source, :crlf), do: String.replace(source, "\n", "\r\n") + defp restore_newlines(source, :lf), do: source end diff --git a/app/test/linear_cli/release/burrito_patches_test.exs b/app/test/linear_cli/release/burrito_patches_test.exs index 7e5369e..da0be80 100644 --- a/app/test/linear_cli/release/burrito_patches_test.exs +++ b/app/test/linear_cli/release/burrito_patches_test.exs @@ -3,6 +3,26 @@ defmodule LinearCli.Release.BurritoPatchesTest do alias LinearCli.Release.BurritoPatches + @unpatched_launcher ~S""" + // On Unix: pipe child stdout through us so we can detect EPIPE from + // the downstream consumer (e.g. `app cmd | head -5`). When the consumer + // exits and breaks the pipe, the copy thread kills the BEAM child. + // On Windows: inherit stdout directly — std.c.read blocks on Windows + // pipes, and the EPIPE group-leader hang is Unix-specific anyway. + var child: std.process.Child = undefined; + var copy_thread: ?std.Thread = null; + + if (builtin.os.tag != .windows) { + // The rest of the spawn block is irrelevant to the source patch. + } + + const term = if (builtin.os.tag != .windows) + child.wait(io) catch { + copy_thread.?.join(); + std.process.exit(0); + } + """ + test "inherits stdout directly when Burrito is connected to a terminal" do launcher_path = Path.expand("../../../deps/burrito/src/erlang_launcher.zig", __DIR__) source = File.read!(launcher_path) @@ -21,4 +41,33 @@ defmodule LinearCli.Release.BurritoPatchesTest do BurritoPatches.patch_source!("a different upstream implementation") end end + + test "matches LF Burrito source when the release hook was checked out with CRLF" do + patch_module_path = Path.expand("../../../release/burrito_patches.exs", __DIR__) + + crlf_module_source = + patch_module_path + |> File.read!() + |> String.replace( + "defmodule LinearCli.Release.BurritoPatches do", + "defmodule LinearCli.Release.BurritoPatchesCRLF do" + ) + |> String.replace(~r/\r?\n/, "\r\n") + + [{crlf_module, _bytecode}] = Code.compile_string(crlf_module_source) + patched = crlf_module.patch_source!(@unpatched_launcher) + + assert patched =~ "const stdout_is_tty = Io.File.stdout().isTty(io) catch false;" + assert patched =~ "if (builtin.os.tag != .windows and !stdout_is_tty)" + refute patched =~ "copy_thread.?.join();" + end + + test "preserves CRLF when Burrito's source uses CRLF" do + source = String.replace(@unpatched_launcher, "\n", "\r\n") + patched = BurritoPatches.patch_source!(source) + + assert patched =~ "\r\n" + refute patched =~ ~r/(?