From 63ded6d4e879301728e35da8394bdd0c7325b9ba Mon Sep 17 00:00:00 2001 From: doorgan Date: Wed, 24 Dec 2025 03:46:22 -0300 Subject: [PATCH 1/3] chore: speed up engine builds with incremental namespacing --- apps/engine/deps.nix | 21 +++- apps/engine/mix.lock | 1 + apps/expert/deps.nix | 21 +++- apps/expert/lib/expert/engine_node.ex | 1 + apps/expert/lib/expert/release.ex | 3 +- apps/expert/mix.lock | 1 + apps/expert/priv/build_engine.exs | 5 +- apps/forge/deps.nix | 21 +++- apps/forge/lib/forge/namespace/file_sync.ex | 112 ++++++++++++++++++++ apps/forge/lib/mix/tasks/classify.ex | 25 +++++ apps/forge/lib/mix/tasks/namespace.ex | 64 +++++++++-- apps/forge/mix.exs | 1 + apps/forge/mix.lock | 1 + 13 files changed, 259 insertions(+), 18 deletions(-) create mode 100644 apps/forge/lib/forge/namespace/file_sync.ex create mode 100644 apps/forge/lib/mix/tasks/classify.ex diff --git a/apps/engine/deps.nix b/apps/engine/deps.nix index 8fbb307a..cb6086cb 100644 --- a/apps/engine/deps.nix +++ b/apps/engine/deps.nix @@ -145,6 +145,23 @@ let with self; { + briefly = + let + version = "0.5.1"; + drv = buildMix { + inherit version; + name = "briefly"; + appConfigPath = ./config; + + src = fetchHex { + inherit version; + pkg = "briefly"; + sha256 = "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"; + }; + }; + in + drv; + elixir_sense = let version = "e3ddc403554050221a2fd19a10a896fa7525bc02"; @@ -165,7 +182,7 @@ let gen_lsp = let - version = "0.11.1"; + version = "0.11.2"; drv = buildMix { inherit version; name = "gen_lsp"; @@ -174,7 +191,7 @@ let src = fetchHex { inherit version; pkg = "gen_lsp"; - sha256 = "78cd7994c0e46399c71e727fe29cfb8ff41e32711c1a30ad4b92203ee0d7920d"; + sha256 = "7a5ccf2403d368a82ffa968ec3993f30d41f4bc2837c69c068ed08c598340a4d"; }; beamDeps = [ diff --git a/apps/engine/mix.lock b/apps/engine/mix.lock index 3e860a81..32302921 100644 --- a/apps/engine/mix.lock +++ b/apps/engine/mix.lock @@ -1,5 +1,6 @@ %{ "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, + "briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, diff --git a/apps/expert/deps.nix b/apps/expert/deps.nix index 20c8e660..efded976 100644 --- a/apps/expert/deps.nix +++ b/apps/expert/deps.nix @@ -145,6 +145,23 @@ let with self; { + briefly = + let + version = "0.5.1"; + drv = buildMix { + inherit version; + name = "briefly"; + appConfigPath = ./config; + + src = fetchHex { + inherit version; + pkg = "briefly"; + sha256 = "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"; + }; + }; + in + drv; + burrito = let version = "1.5.0"; @@ -195,7 +212,7 @@ let gen_lsp = let - version = "0.11.1"; + version = "0.11.2"; drv = buildMix { inherit version; name = "gen_lsp"; @@ -204,7 +221,7 @@ let src = fetchHex { inherit version; pkg = "gen_lsp"; - sha256 = "78cd7994c0e46399c71e727fe29cfb8ff41e32711c1a30ad4b92203ee0d7920d"; + sha256 = "7a5ccf2403d368a82ffa968ec3993f30d41f4bc2837c69c068ed08c598340a4d"; }; beamDeps = [ diff --git a/apps/expert/lib/expert/engine_node.ex b/apps/expert/lib/expert/engine_node.ex index 0950f1c2..54233b65 100644 --- a/apps/expert/lib/expert/engine_node.ex +++ b/apps/expert/lib/expert/engine_node.ex @@ -89,6 +89,7 @@ defmodule Expert.EngineNode do {:error, reason} -> IO.puts("error starting node: #{inspect(reason)}") + IO.puts("error starting node:\n #{inspect(reason)}") end end diff --git a/apps/expert/lib/expert/release.ex b/apps/expert/lib/expert/release.ex index 549f6c0a..533f2a7b 100644 --- a/apps/expert/lib/expert/release.ex +++ b/apps/expert/lib/expert/release.ex @@ -1,6 +1,7 @@ defmodule Expert.Release do def assemble(release) do - Mix.Task.run(:namespace, [release.path]) + # In-place namespacing: both source and output are the same path + Mix.Task.run(:namespace, [release.path, release.path]) expert_root = Path.expand("../../../..", __DIR__) engine_path = Path.join([expert_root, "apps", "engine"]) diff --git a/apps/expert/mix.lock b/apps/expert/mix.lock index 2f2186f7..909a87c6 100644 --- a/apps/expert/mix.lock +++ b/apps/expert/mix.lock @@ -1,4 +1,5 @@ %{ + "briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "burrito": {:hex, :burrito, "1.5.0", "d68ec01df2871f1d5bc603b883a78546c75761ac73c1bec1b7ae2cc74790fcd1", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "3861abda7bffa733862b48da3e03df0b4cd41abf6fd24b91745f5c16d971e5fa"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, diff --git a/apps/expert/priv/build_engine.exs b/apps/expert/priv/build_engine.exs index 5d7e6a91..d221794f 100644 --- a/apps/expert/priv/build_engine.exs +++ b/apps/expert/priv/build_engine.exs @@ -31,9 +31,6 @@ install_path = Mix.install_project_dir() dev_build_path = Path.join([install_path, "_build", "dev"]) ns_build_path = Path.join([install_path, "_build", "dev_ns"]) -File.rm_rf!(ns_build_path) -File.cp_r!(dev_build_path, ns_build_path) - -Mix.Task.run("namespace", [ns_build_path, "--cwd", install_path]) +Mix.Task.run("namespace", [dev_build_path, ns_build_path, "--cwd", install_path]) IO.puts("engine_path:" <> ns_build_path) diff --git a/apps/forge/deps.nix b/apps/forge/deps.nix index 7cf236b7..785bffe8 100644 --- a/apps/forge/deps.nix +++ b/apps/forge/deps.nix @@ -145,9 +145,26 @@ let with self; { + briefly = + let + version = "0.5.1"; + drv = buildMix { + inherit version; + name = "briefly"; + appConfigPath = ./config; + + src = fetchHex { + inherit version; + pkg = "briefly"; + sha256 = "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"; + }; + }; + in + drv; + gen_lsp = let - version = "0.11.1"; + version = "0.11.2"; drv = buildMix { inherit version; name = "gen_lsp"; @@ -156,7 +173,7 @@ let src = fetchHex { inherit version; pkg = "gen_lsp"; - sha256 = "78cd7994c0e46399c71e727fe29cfb8ff41e32711c1a30ad4b92203ee0d7920d"; + sha256 = "7a5ccf2403d368a82ffa968ec3993f30d41f4bc2837c69c068ed08c598340a4d"; }; beamDeps = [ diff --git a/apps/forge/lib/forge/namespace/file_sync.ex b/apps/forge/lib/forge/namespace/file_sync.ex new file mode 100644 index 00000000..f0d0bd4d --- /dev/null +++ b/apps/forge/lib/forge/namespace/file_sync.ex @@ -0,0 +1,112 @@ +defmodule Forge.Namespace.FileSync do + defmodule Classification do + defstruct changed: [], + new: [], + deleted: [] + end + + alias __MODULE__.Classification + + @doc """ + Classifies files into changed, new, and deleted categories. + + It looks at `**/ebin/*` files in both the base directory and output directory, + applying namespacing to the file names in the output directory. + Files can be `.beam` or `.app` files. + + Then compares the mtimes of the files to determine their classification. + + If files in output_directory are not present in base_directory, they are classified as deleted. + """ + def classify_files(same, same), + do: %Classification{ + changed: [], + new: [], + deleted: [] + } + + def classify_files(base_directory, output_directory) do + # Files in base directory are not namespaced, eg: + # lib/foo/ebin/Elixir.Foo.Bar.beam + # lib/foo/ebin/foo.app + # + # Files in output directory are namespaced, eg: + # lib/xp_foo/ebin/Elixir.XPFoo.Bar.beam + # lib/xp_foo/ebin/xp_foo.app + # + # We need to compare the files by applying namespacing to the base directory paths, + # then comparing mtimes. + # + # For any files in output_directory that don't have a corresponding file in base_directory, + # we classify them as deleted. + + base_files = find_files(Path.join(base_directory, "lib")) + output_files = find_files(Path.join(output_directory, "lib")) + + base_map = + Enum.reduce(base_files, %{}, fn base_file, acc -> + relative_path = Path.relative_to(base_file, base_directory) + + namespaced_relative_path = + relative_path + |> Forge.Namespace.Path.apply() + |> maybe_namespace_filename() + + dest_path = Path.join(output_directory, namespaced_relative_path) + Map.put(acc, base_file, dest_path) + end) + + expected_dest_files = base_map |> Map.values() |> MapSet.new() + output_set = MapSet.new(output_files) + + classification = + Enum.reduce(base_map, %Classification{}, fn {base_file, dest_path}, acc -> + if File.exists?(dest_path) do + base_mtime = File.stat!(base_file).mtime + output_mtime = File.stat!(dest_path).mtime + + if base_mtime > output_mtime do + %{acc | changed: [{base_file, dest_path} | acc.changed]} + else + acc + end + else + %{acc | new: [{base_file, dest_path} | acc.new]} + end + end) + + deleted_files = + output_set + |> MapSet.difference(MapSet.new(expected_dest_files)) + |> MapSet.to_list() + + %{classification | deleted: deleted_files} + end + + defp find_files(directory) do + [directory, "**", "*"] + |> Path.join() + |> Path.wildcard() + |> Enum.filter(&File.regular?/1) + end + + defp maybe_namespace_filename(file_path) do + # namespace filename for .beam and .app files + extname = Path.extname(file_path) + + if extname in [".beam", ".app"] do + dirname = Path.dirname(file_path) + basename = Path.basename(file_path, extname) + + namespaced_basename = + basename + |> String.to_atom() + |> Forge.Namespace.Module.apply() + |> Atom.to_string() + + Path.join(dirname, namespaced_basename <> extname) + else + file_path + end + end +end diff --git a/apps/forge/lib/mix/tasks/classify.ex b/apps/forge/lib/mix/tasks/classify.ex new file mode 100644 index 00000000..77d8e7c3 --- /dev/null +++ b/apps/forge/lib/mix/tasks/classify.ex @@ -0,0 +1,25 @@ +defmodule Mix.Tasks.Classify do + use Mix.Task + + def run([base_directory, output_directory]) do + classified_files = Forge.Namespace.FileSync.classify_files(base_directory, output_directory) + + Mix.Shell.IO.info("Changed files:") + + Enum.each(classified_files.changed, fn {base, output} -> + Mix.Shell.IO.info(" Changed: #{base} -> #{output}") + end) + + Mix.Shell.IO.info("New files:") + + Enum.each(classified_files.new, fn {base, output} -> + Mix.Shell.IO.info(" New: #{base} -> #{output}") + end) + + Mix.Shell.IO.info("Deleted files:") + + Enum.each(classified_files.deleted, fn output -> + Mix.Shell.IO.info(" Deleted: #{output}") + end) + end +end diff --git a/apps/forge/lib/mix/tasks/namespace.ex b/apps/forge/lib/mix/tasks/namespace.ex index 7f96c73d..ff9faae1 100644 --- a/apps/forge/lib/mix/tasks/namespace.ex +++ b/apps/forge/lib/mix/tasks/namespace.ex @@ -30,7 +30,7 @@ defmodule Mix.Tasks.Namespace do require Logger - def run([base_directory | opts]) do + def run([base_directory, output_directory | opts]) do {args, _, _} = OptionParser.parse(opts, strict: [cwd: :string] @@ -44,14 +44,64 @@ defmodule Mix.Tasks.Namespace do # Otherwise only the @extra_apps will be cached init() - Transform.Apps.apply_to_all(base_directory) - Transform.Beams.apply_to_all(base_directory) - Transform.Scripts.apply_to_all(base_directory) + File.mkdir_p!(output_directory) + + if base_directory == output_directory do + apply_transforms(base_directory) + else + incremental_transforms(base_directory, output_directory) + end + end + + defp apply_transforms(directory) do + Transform.Apps.apply_to_all(directory) + Transform.Beams.apply_to_all(directory) + Transform.Scripts.apply_to_all(directory) # The boot file transform just turns script files into boot files # so it must come after the script file transform - Transform.Boots.apply_to_all(base_directory) - Transform.Configs.apply_to_all(base_directory) - Transform.AppDirectories.apply_to_all(base_directory) + Transform.Boots.apply_to_all(directory) + Transform.Configs.apply_to_all(directory) + Transform.AppDirectories.apply_to_all(directory) + end + + defp incremental_transforms(base_directory, output_directory) do + Application.ensure_all_started(:briefly) + + classification = + Forge.Namespace.FileSync.classify_files(base_directory, output_directory) + + tmp_dir = Briefly.create!(directory: true) + + entries_to_namespace = + classification.new ++ classification.changed + + Mix.Shell.IO.info(""" + Namespacing #{length(entries_to_namespace)} files: + New: #{length(classification.new)} + Changed: #{length(classification.changed)} + Deleted: #{length(classification.deleted)} + """) + + # Copy new and changed files to a temp directory + Enum.each(entries_to_namespace, fn {src, _dest} -> + relative_path = Path.relative_to(src, base_directory) + tmp_dest = Path.join(tmp_dir, relative_path) + File.mkdir_p!(Path.dirname(tmp_dest)) + File.cp!(src, tmp_dest) + end) + + # Delete removed files from output directory + Enum.each(classification.deleted, fn dest -> + if File.exists?(dest) do + File.rm!(dest) + end + end) + + # Apply transforms to temp directory + apply_transforms(tmp_dir) + + # Copy temp directory back to output directory + File.cp_r!(tmp_dir, output_directory) end def app_names do diff --git a/apps/forge/mix.exs b/apps/forge/mix.exs index 9b32fed3..ffeac8ba 100644 --- a/apps/forge/mix.exs +++ b/apps/forge/mix.exs @@ -36,6 +36,7 @@ defmodule Forge.MixProject do defp deps do [ {:benchee, "~> 1.3", only: :test}, + {:briefly, "~> 0.5"}, Mix.Credo.dependency(), Mix.Dialyzer.dependency(), {:deps_nix, "~> 2.4", only: :dev}, diff --git a/apps/forge/mix.lock b/apps/forge/mix.lock index 25c6c1eb..ae25be64 100644 --- a/apps/forge/mix.lock +++ b/apps/forge/mix.lock @@ -1,5 +1,6 @@ %{ "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, + "briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, From e7582b413c6df395c7333b69d09abfa50a9640f4 Mon Sep 17 00:00:00 2001 From: doorgan Date: Wed, 24 Dec 2025 09:29:01 -0300 Subject: [PATCH 2/3] chore: add file sync tests --- apps/expert/priv/build_engine.exs | 2 +- .../lib/forge/namespace/transform/beams.ex | 26 ++- .../lib/forge/namespace/transform/configs.ex | 1 + apps/forge/lib/mix/tasks/namespace.ex | 17 +- .../test/forge/namespace/file_sync_test.exs | 185 ++++++++++++++++++ 5 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 apps/forge/test/forge/namespace/file_sync_test.exs diff --git a/apps/expert/priv/build_engine.exs b/apps/expert/priv/build_engine.exs index d221794f..8b9f8929 100644 --- a/apps/expert/priv/build_engine.exs +++ b/apps/expert/priv/build_engine.exs @@ -31,6 +31,6 @@ install_path = Mix.install_project_dir() dev_build_path = Path.join([install_path, "_build", "dev"]) ns_build_path = Path.join([install_path, "_build", "dev_ns"]) -Mix.Task.run("namespace", [dev_build_path, ns_build_path, "--cwd", install_path]) +Mix.Task.run("namespace", [dev_build_path, ns_build_path, "--cwd", install_path, "--no-progress"]) IO.puts("engine_path:" <> ns_build_path) diff --git a/apps/forge/lib/forge/namespace/transform/beams.ex b/apps/forge/lib/forge/namespace/transform/beams.ex index ca44c363..65ef6509 100644 --- a/apps/forge/lib/forge/namespace/transform/beams.ex +++ b/apps/forge/lib/forge/namespace/transform/beams.ex @@ -6,7 +6,7 @@ defmodule Forge.Namespace.Transform.Beams do alias Forge.Namespace.Abstract alias Forge.Namespace.Code - def apply_to_all(base_directory) do + def apply_to_all(base_directory, opts) do Mix.Shell.IO.info("Rewriting .beam files") consolidated_beams = find_consolidated_beams(base_directory) app_beams = find_app_beams(base_directory) @@ -29,7 +29,7 @@ defmodule Forge.Namespace.Transform.Beams do |> Stream.run() end) - block_until_done(0, total_files) + block_until_done(0, total_files, opts) end def apply(path) do @@ -46,21 +46,29 @@ defmodule Forge.Namespace.Transform.Beams do defp changed?(same, same), do: false defp changed?(_, _), do: true - defp block_until_done(same, same) do - Mix.Shell.IO.info("\n done") + defp block_until_done(same, same, opts) do + if !opts[:no_progress] do + IO.write("\n") + end + + Mix.Shell.IO.info("Finished namespacing .beam files") end - defp block_until_done(current, max) do + defp block_until_done(current, max, opts) do receive do :progress -> :ok end current = current + 1 - IO.write("\r") - percent_complete = format_percent(current, max) - IO.write(" Applying namespace: #{percent_complete} complete") - block_until_done(current, max) + if !opts[:no_progress] do + IO.write("\r") + percent_complete = format_percent(current, max) + + IO.write(" Applying namespace: #{percent_complete} complete") + end + + block_until_done(current, max, opts) end defp apply_and_update_progress(beam_file, caller) do diff --git a/apps/forge/lib/forge/namespace/transform/configs.ex b/apps/forge/lib/forge/namespace/transform/configs.ex index f64ee44e..88abed28 100644 --- a/apps/forge/lib/forge/namespace/transform/configs.ex +++ b/apps/forge/lib/forge/namespace/transform/configs.ex @@ -3,6 +3,7 @@ defmodule Forge.Namespace.Transform.Configs do base_directory |> Path.join("**/runtime.exs") |> Path.wildcard() + |> Enum.filter(&File.regular?/1) |> Enum.map(&Path.absname/1) |> tap(fn paths -> Mix.Shell.IO.info("Rewriting #{length(paths)} config scripts.") diff --git a/apps/forge/lib/mix/tasks/namespace.ex b/apps/forge/lib/mix/tasks/namespace.ex index ff9faae1..1c9c48b4 100644 --- a/apps/forge/lib/mix/tasks/namespace.ex +++ b/apps/forge/lib/mix/tasks/namespace.ex @@ -33,10 +33,11 @@ defmodule Mix.Tasks.Namespace do def run([base_directory, output_directory | opts]) do {args, _, _} = OptionParser.parse(opts, - strict: [cwd: :string] + strict: [cwd: :string, no_progress: :boolean] ) cwd = Keyword.get(args, :cwd, File.cwd!()) + no_progress = Keyword.get(args, :no_progress, false) :persistent_term.put(:forge_namespace_cwd, cwd) @@ -46,16 +47,18 @@ defmodule Mix.Tasks.Namespace do File.mkdir_p!(output_directory) + opts = [no_progress: no_progress] + if base_directory == output_directory do - apply_transforms(base_directory) + apply_transforms(base_directory, opts) else - incremental_transforms(base_directory, output_directory) + incremental_transforms(base_directory, output_directory, opts) end end - defp apply_transforms(directory) do + defp apply_transforms(directory, opts) do Transform.Apps.apply_to_all(directory) - Transform.Beams.apply_to_all(directory) + Transform.Beams.apply_to_all(directory, opts) Transform.Scripts.apply_to_all(directory) # The boot file transform just turns script files into boot files # so it must come after the script file transform @@ -64,7 +67,7 @@ defmodule Mix.Tasks.Namespace do Transform.AppDirectories.apply_to_all(directory) end - defp incremental_transforms(base_directory, output_directory) do + defp incremental_transforms(base_directory, output_directory, opts) do Application.ensure_all_started(:briefly) classification = @@ -98,7 +101,7 @@ defmodule Mix.Tasks.Namespace do end) # Apply transforms to temp directory - apply_transforms(tmp_dir) + apply_transforms(tmp_dir, opts) # Copy temp directory back to output directory File.cp_r!(tmp_dir, output_directory) diff --git a/apps/forge/test/forge/namespace/file_sync_test.exs b/apps/forge/test/forge/namespace/file_sync_test.exs new file mode 100644 index 00000000..43dc4f32 --- /dev/null +++ b/apps/forge/test/forge/namespace/file_sync_test.exs @@ -0,0 +1,185 @@ +defmodule Forge.Namespace.FileSyncTest do + use ExUnit.Case, async: false + use Patch + + alias Forge.Namespace.FileSync + alias Forge.Namespace.FileSync.Classification + + @moduletag tmp_dir: true + + setup do + patch(Mix.Tasks.Namespace, :app_to_root_modules, %{ + foo: [Foo], + forge: [Forge] + }) + + :ok + end + + describe "classify_files/2" do + test "returns empty classification when directories match", %{tmp_dir: tmp_dir} do + assert %Classification{changed: [], new: [], deleted: []} = + FileSync.classify_files(tmp_dir, tmp_dir) + end + + test "marks base-only files as new with namespacing", %{tmp_dir: tmp_dir} do + {base_dir, output_dir} = dirs(tmp_dir) + + base_files = [ + write_file(Path.join(base_dir, "lib/foo/ebin/Elixir.Foo.beam")), + write_file(Path.join(base_dir, "lib/foo/ebin/foo.app")), + write_file(Path.join(base_dir, "lib/foo/ebin/erl_module.beam")) + ] + + classification = FileSync.classify_files(base_dir, output_dir) + + expected_new = + [ + {Enum.at(base_files, 0), Path.join(output_dir, "lib/xp_foo/ebin/Elixir.XPFoo.beam")}, + {Enum.at(base_files, 1), Path.join(output_dir, "lib/xp_foo/ebin/xp_foo.app")}, + {Enum.at(base_files, 2), Path.join(output_dir, "lib/xp_foo/ebin/erl_module.beam")} + ] + |> MapSet.new() + + assert %Classification{new: new, changed: [], deleted: []} = classification + assert MapSet.new(new) == expected_new + end + + test "marks older output files as changed", %{tmp_dir: tmp_dir} do + {base_dir, output_dir} = dirs(tmp_dir) + + base_files = [ + write_file(Path.join(base_dir, "lib/foo/ebin/Elixir.Foo.beam")), + write_file(Path.join(base_dir, "lib/foo/ebin/foo.app")), + write_file(Path.join(base_dir, "lib/foo/ebin/erl_module.beam")) + ] + + dest_changed = Path.join(output_dir, "lib/xp_foo/ebin/Elixir.XPFoo.beam") + dest_new = Path.join(output_dir, "lib/xp_foo/ebin/xp_foo.app") + dest_erl = Path.join(output_dir, "lib/xp_foo/ebin/erl_module.beam") + + write_file(dest_changed) + write_file(dest_new) + write_file(dest_erl) + + older = {{2020, 1, 1}, {0, 0, 0}} + newer = {{2020, 1, 1}, {0, 0, 1}} + + File.touch!(dest_changed, older) + File.touch!(dest_new, older) + File.touch!(dest_erl, older) + + Enum.each(base_files, &File.touch!(&1, newer)) + + classification = FileSync.classify_files(base_dir, output_dir) + + expected_changed = + [ + {Enum.at(base_files, 0), dest_changed}, + {Enum.at(base_files, 1), dest_new}, + {Enum.at(base_files, 2), dest_erl} + ] + |> MapSet.new() + + assert %Classification{changed: changed, new: [], deleted: []} = classification + assert MapSet.new(changed) == expected_changed + end + + test "marks output-only files as deleted", %{tmp_dir: tmp_dir} do + {base_dir, output_dir} = dirs(tmp_dir) + + deleted_files = [ + "lib/releases/0.1.0/consolidated/Elixir.XPForge.Document.Container.beam", + "lib/releases/0.1.0/runtime.exs", + "lib/releases/0.1.0/start.boot" + ] + + Enum.each(deleted_files, fn rel -> + path = Path.join(output_dir, rel) + write_file(path) + assert File.regular?(path) + end) + + created_files = + output_dir + |> Path.join("lib/**/*") + |> Path.wildcard() + |> Enum.filter(&File.regular?/1) + + assert length(created_files) == length(deleted_files) + + classification = FileSync.classify_files(base_dir, output_dir) + + assert %Classification{deleted: deleted, new: [], changed: []} = classification + + assert MapSet.new(deleted) == + deleted_files + |> Enum.map(&Path.join(output_dir, &1)) + |> MapSet.new() + end + + test "handles mixed new, changed, and deleted entries", %{tmp_dir: tmp_dir} do + {base_dir, output_dir} = dirs(tmp_dir) + + base_new = write_file(Path.join(base_dir, "lib/foo/ebin/foo.app")) + base_changed = write_file(Path.join(base_dir, "lib/foo/ebin/Elixir.Foo.beam")) + + dest_changed = Path.join(output_dir, "lib/xp_foo/ebin/Elixir.XPFoo.beam") + write_file(dest_changed) + + older = {{2020, 1, 1}, {0, 0, 0}} + newer = {{2020, 1, 1}, {0, 0, 1}} + + File.touch!(dest_changed, older) + File.touch!(base_changed, newer) + + deleted_files = [ + "lib/releases/0.1.0/consolidated/Elixir.XPForge.Document.Container.beam", + "lib/releases/0.1.0/runtime.exs" + ] + + Enum.each(deleted_files, fn rel -> + path = Path.join(output_dir, rel) + write_file(path) + assert File.regular?(path) + end) + + File.touch!(base_new, newer) + + classification = FileSync.classify_files(base_dir, output_dir) + + expected_new = + [{base_new, Path.join(output_dir, "lib/xp_foo/ebin/xp_foo.app")}] + |> MapSet.new() + + expected_changed = + [{base_changed, dest_changed}] + |> MapSet.new() + + expected_deleted = + deleted_files + |> Enum.map(&Path.join(output_dir, &1)) + |> MapSet.new() + + assert %Classification{new: new, changed: changed, deleted: deleted} = classification + + assert MapSet.new(new) == expected_new + assert MapSet.new(changed) == expected_changed + assert MapSet.new(deleted) == expected_deleted + end + end + + defp dirs(tmp_dir) do + base_dir = Path.join(tmp_dir, "base") + output_dir = Path.join(tmp_dir, "output") + File.mkdir_p!(base_dir) + File.mkdir_p!(output_dir) + {base_dir, output_dir} + end + + defp write_file(path) do + File.mkdir_p!(Path.dirname(path)) + File.write!(path, "") + path + end +end From 5e741b84077478035d548397e4ef7c116d9d6590 Mon Sep 17 00:00:00 2001 From: doorgan Date: Wed, 24 Dec 2025 10:05:39 -0300 Subject: [PATCH 3/3] refactor: move copy/deletion logic to Namespace.FileSync --- apps/forge/lib/forge/namespace/file_sync.ex | 52 ++++++++++++++------- apps/forge/lib/mix/tasks/namespace.ex | 16 +------ 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/apps/forge/lib/forge/namespace/file_sync.ex b/apps/forge/lib/forge/namespace/file_sync.ex index f0d0bd4d..df262f3a 100644 --- a/apps/forge/lib/forge/namespace/file_sync.ex +++ b/apps/forge/lib/forge/namespace/file_sync.ex @@ -7,12 +7,17 @@ defmodule Forge.Namespace.FileSync do alias __MODULE__.Classification + @type classification :: %Classification{ + changed: list({String.t(), String.t()}), + new: list({String.t(), String.t()}), + deleted: list(String.t()) + } + @doc """ Classifies files into changed, new, and deleted categories. - It looks at `**/ebin/*` files in both the base directory and output directory, + It looks at files in both the base directory and output directory, applying namespacing to the file names in the output directory. - Files can be `.beam` or `.app` files. Then compares the mtimes of the files to determine their classification. @@ -26,20 +31,6 @@ defmodule Forge.Namespace.FileSync do } def classify_files(base_directory, output_directory) do - # Files in base directory are not namespaced, eg: - # lib/foo/ebin/Elixir.Foo.Bar.beam - # lib/foo/ebin/foo.app - # - # Files in output directory are namespaced, eg: - # lib/xp_foo/ebin/Elixir.XPFoo.Bar.beam - # lib/xp_foo/ebin/xp_foo.app - # - # We need to compare the files by applying namespacing to the base directory paths, - # then comparing mtimes. - # - # For any files in output_directory that don't have a corresponding file in base_directory, - # we classify them as deleted. - base_files = find_files(Path.join(base_directory, "lib")) output_files = find_files(Path.join(output_directory, "lib")) @@ -83,6 +74,35 @@ defmodule Forge.Namespace.FileSync do %{classification | deleted: deleted_files} end + @doc """ + Copy new and changed files into a destination root (e.g., tmp dir) while + preserving relative paths and namespacing. + """ + def copy_new_and_changed(%Classification{} = classification, base_directory, destination_root) do + Enum.each(classification.new ++ classification.changed, fn {src, _dest} -> + relative_path = Path.relative_to(src, base_directory) + destination_path = Path.join(destination_root, relative_path) + + File.mkdir_p!(Path.dirname(destination_path)) + File.cp!(src, destination_path) + end) + + :ok + end + + @doc """ + Delete files listed in the classification from the given root. + """ + def delete_removed(%Classification{} = classification) do + Enum.each(classification.deleted, fn dest -> + if File.exists?(dest) do + File.rm!(dest) + end + end) + + :ok + end + defp find_files(directory) do [directory, "**", "*"] |> Path.join() diff --git a/apps/forge/lib/mix/tasks/namespace.ex b/apps/forge/lib/mix/tasks/namespace.ex index 1c9c48b4..acdde76f 100644 --- a/apps/forge/lib/mix/tasks/namespace.ex +++ b/apps/forge/lib/mix/tasks/namespace.ex @@ -85,20 +85,8 @@ defmodule Mix.Tasks.Namespace do Deleted: #{length(classification.deleted)} """) - # Copy new and changed files to a temp directory - Enum.each(entries_to_namespace, fn {src, _dest} -> - relative_path = Path.relative_to(src, base_directory) - tmp_dest = Path.join(tmp_dir, relative_path) - File.mkdir_p!(Path.dirname(tmp_dest)) - File.cp!(src, tmp_dest) - end) - - # Delete removed files from output directory - Enum.each(classification.deleted, fn dest -> - if File.exists?(dest) do - File.rm!(dest) - end - end) + Forge.Namespace.FileSync.copy_new_and_changed(classification, base_directory, tmp_dir) + Forge.Namespace.FileSync.delete_removed(classification) # Apply transforms to temp directory apply_transforms(tmp_dir, opts)