Files
bDS2/test/bds/desktop/shell_live_test.exs
2026-04-26 17:36:45 +02:00

781 lines
25 KiB
Elixir

defmodule BDS.Desktop.ShellLiveTest do
use ExUnit.Case, async: false
import Phoenix.ConnTest
import Phoenix.LiveViewTest
alias BDS.Persistence
alias BDS.Metadata
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.Projects
alias BDS.Repo
alias BDS.Tags
alias BDS.UI.{Session, Workbench}
@endpoint BDS.Desktop.Endpoint
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
temp_dir = Path.join(System.tmp_dir!(), "bds-shell-live-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = Projects.create_project(%{name: "Shell Project", data_path: temp_dir})
{:ok, _project} = Projects.set_active_project(project.id)
original_shell_platform = Application.get_env(:bds, :shell_platform)
original_git_remote_state_provider = Application.get_env(:bds, :git_remote_state_provider)
on_exit(fn ->
if is_nil(original_shell_platform) do
Application.delete_env(:bds, :shell_platform)
else
Application.put_env(:bds, :shell_platform, original_shell_platform)
end
if is_nil(original_git_remote_state_provider) do
Application.delete_env(:bds, :git_remote_state_provider)
else
Application.put_env(:bds, :git_remote_state_provider, original_git_remote_state_provider)
end
end)
%{project: project, temp_dir: temp_dir}
end
test "shell live owns pane visibility and activity selection on the server" do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(data-testid="status-bar")
assert html =~ ~s(data-testid="status-task-button")
assert html =~ ~s(class="panel-shell is-hidden")
assert html =~ ~s(data-testid="activity-button")
assert html =~ ~s(data-view="posts")
assert html =~ ~s(data-view="media")
assert html =~ ~s(aria-label="Posts")
html =
view
|> element("[data-testid='toggle-sidebar']")
|> render_click()
assert html =~ ~s(class="sidebar-shell is-hidden")
html =
view
|> element("[data-testid='toggle-panel']")
|> render_click()
assert html =~ ~s(data-region="panel")
refute html =~ ~s(class="panel-shell is-hidden")
assert html =~ ~s(data-testid="panel-close")
html =
view
|> element("[data-testid='panel-close']")
|> render_click()
assert html =~ ~s(class="panel-shell is-hidden")
html =
view
|> element("[data-testid='activity-button'][data-view='media']")
|> render_click()
assert html =~ ~s(aria-label="Media")
assert html =~ ~s(data-view="media")
html =
view
|> element("[data-testid='activity-button'][data-view='settings']")
|> render_click()
assert html =~ ~s(data-testid="sidebar-open-item")
html =
view
|> element("[data-testid='sidebar-open-item'][data-item-id='settings-project']")
|> render_click()
assert html =~ ~s(data-tab-type="settings")
assert html =~ ">Settings<"
html =
view
|> element("[data-testid='tab-close'][data-tab-type='settings'][data-tab-id='settings']")
|> render_click()
refute html =~ ~s(data-tab-type="settings")
assert html =~ ~s(class="tab-bar-empty")
end
test "macos hides the custom titlebar and moves shell toggles into the status bar" do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
refute html =~ ~s(data-testid="window-titlebar")
refute html =~ ~s(data-testid="window-titlebar-menu-bar")
refute html =~ ~s(data-testid="window-titlebar-menu-button")
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
assert html =~ ~s(data-testid="status-shell-controls")
assert html =~ ~s(data-testid="toggle-sidebar")
assert html =~ ~s(data-testid="toggle-panel")
assert html =~ ~s(data-testid="toggle-assistant")
html =
view
|> element("[data-testid='toggle-sidebar']")
|> render_click()
assert html =~ ~s(class="sidebar-shell is-hidden")
html =
render_hook(view, "native_menu_action", %{"action" => "edit_preferences"})
assert html =~ ~s(data-tab-type="settings")
assert html =~ ">Settings<"
end
test "titlebar menu matches the old shell contract on windows and linux" do
Application.put_env(:bds, :shell_platform, {:unix, :linux})
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
refute html =~ ~s(class="window-titlebar is-mac")
assert html =~ ~s(data-testid="window-titlebar-menu-bar")
assert html =~ ~s(data-testid="window-titlebar-menu-button")
assert html =~ ~s(data-menu-group="file")
assert html =~ ~s(>File<)
html =
view
|> element("[data-testid='window-titlebar-menu-button'][data-menu-group='file']")
|> render_click()
assert html =~ ~s(data-testid="window-titlebar-menu-dropdown")
assert html =~ ~s(data-testid="window-titlebar-menu-item")
assert html =~ ~s(data-menu-action="new_post")
assert html =~ ~s(>New Post<)
html =
view
|> element("[data-testid='window-titlebar-menu-button'][data-menu-group='edit']")
|> render_click()
assert html =~ ~s(data-menu-action="edit_preferences")
html =
view
|> element("[data-testid='window-titlebar-menu-item'][data-menu-action='edit_preferences']")
|> render_click()
assert html =~ ~s(data-tab-type="settings")
assert html =~ ">Settings<"
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
end
test "titlebar menu keyboard navigation is owned by liveview on windows and linux" do
Application.put_env(:bds, :shell_platform, {:unix, :linux})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
view
|> element("[data-testid='window-titlebar-menu-button'][data-menu-group='file']")
|> render_click()
assert html =~ ~s(data-open-menu-group="file")
html = render_keydown(view, "titlebar_menu_keydown", %{key: "ArrowRight"})
assert html =~ ~s(data-open-menu-group="edit")
assert html =~ ~s(data-menu-action="edit_preferences")
html = render_keydown(view, "titlebar_menu_keydown", %{key: "End"})
assert html =~ ~s(class="window-titlebar-menu-item is-keyboard-active")
assert html =~ ~s(data-menu-action="edit_preferences")
html = render_keydown(view, "titlebar_menu_keydown", %{key: "Enter"})
assert html =~ ~s(data-tab-type="settings")
assert html =~ ">Settings<"
refute html =~ ~s(data-testid="window-titlebar-menu-dropdown")
end
test "workbench session restore reopens permanent and transient tabs and selected activity" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
session_payload =
Workbench.new()
|> Workbench.click_activity(:media)
|> Workbench.open_tab(:post, "post-1", :pin)
|> Workbench.open_tab(:media, "media-1", :preview)
|> Session.serialize()
html = render_hook(view, "restore_workbench_session", %{"session" => session_payload})
assert html =~ ~s(data-view="media")
assert html =~ ~s(data-active="true")
assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="post-1")
assert html =~ ~s(data-tab-type="media")
assert html =~ ~s(data-tab-id="media-1")
assert html =~ ~s(class="tab active transient")
end
test "shell live renders the legacy git activity badge from remote behind count" do
Application.put_env(:bds, :git_remote_state_provider, fn _project_id, _opts ->
{:ok, %{local_branch: "main", upstream_branch: "origin/main", has_upstream: true, ahead: 0, behind: 7}}
end)
{:ok, _view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-view="git")
assert html =~ ~s(class="activity-bar-badge")
assert html =~ ">7<"
end
test "assistant sidebar exposes context, prompt, and offline-gated transcript" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
view
|> element("[data-testid='toggle-assistant']")
|> render_click()
assert html =~ ~s(data-testid="assistant-shell")
assert html =~ ~s(data-testid="assistant-context")
assert html =~ ~s(data-testid="assistant-prompt-form")
assert html =~ ~s(data-testid="assistant-prompt-input")
assert html =~ ~s(data-testid="assistant-start-button")
assert html =~ ~s(>Dashboard<)
html =
render_submit(view, "submit_assistant_prompt", %{
"assistant" => %{"prompt" => "Summarize the current project"}
})
assert html =~ ~s(data-testid="assistant-message-user")
assert html =~ ~s(data-testid="assistant-message-assistant")
assert html =~ "Summarize the current project"
assert html =~ "Automatic AI actions stay gated by airplane mode."
end
test "sidebar open supports preview and pin intents for entity tabs" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "open_sidebar_item", %{
"route" => "post",
"id" => "post-1",
"title" => "First Post",
"subtitle" => "draft"
})
assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="post-1")
assert html =~ ~s(class="tab active transient")
html =
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => "post-1",
"title" => "First Post",
"subtitle" => "draft"
})
assert html =~ ~s(data-tab-id="post-1")
refute html =~ ~s(class="tab active transient")
html =
render_click(view, "open_sidebar_item", %{
"route" => "post",
"id" => "page-1",
"title" => "About Page",
"subtitle" => "page"
})
assert html =~ ~s(data-tab-id="post-1")
assert html =~ ~s(data-tab-id="page-1")
assert String.contains?(html, ">First Post<")
assert String.contains?(html, ">About Page<")
_html =
render_click(view, "pin_sidebar_item", %{
"route" => "media",
"id" => "media-1",
"title" => "hero.png",
"subtitle" => "12 KB"
})
html =
render_click(view, "open_sidebar_item", %{
"route" => "media",
"id" => "media-2",
"title" => "cover.png",
"subtitle" => "8 KB"
})
assert html =~ ~s(data-tab-id="media-1")
assert html =~ ~s(data-tab-id="media-2")
assert String.contains?(html, ">hero.png<")
assert String.contains?(html, ">cover.png<")
end
test "global shortcuts route through the shared command model" do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(class="panel-shell is-hidden")
html = render_keydown(view, "shortcut", %{key: "b", meta: true})
assert html =~ ~s(class="sidebar-shell is-hidden")
html = render_keydown(view, "shortcut", %{key: "j", meta: true})
refute html =~ ~s(class="panel-shell is-hidden")
html = render_keydown(view, "shortcut", %{key: "2", meta: true})
assert html =~ ~s(data-view="media")
html =
render_click(view, "pin_sidebar_item", %{
"route" => "media",
"id" => "media-1",
"title" => "hero.png",
"subtitle" => "12 KB"
})
assert html =~ ~s(data-tab-id="media-1")
html = render_keydown(view, "shortcut", %{key: "w", meta: true})
refute html =~ ~s(data-tab-id="media-1")
end
test "hiding the sidebar collapses its width to zero" do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(style="width: 280px;")
html =
view
|> element("[data-testid='toggle-sidebar']")
|> render_click()
assert html =~ ~s(class="sidebar-shell is-hidden")
assert html =~ ~s(style="width: 0px;")
end
test "layout hooks sync persisted widths and apply drag resizing" do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(style="width: 280px;")
html = render_hook(view, "sync_layout", %{"sidebar_width" => 420, "assistant_sidebar_width" => 480})
assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(style="width: 420px;")
html =
view
|> element("[data-testid='toggle-assistant']")
|> render_click()
assert html =~ ~s(data-testid="assistant-shell")
assert html =~ ~s(style="width: 480px;")
html = render_hook(view, "resize_panel", %{"target" => "sidebar", "width" => 460})
assert html =~ ~s(data-testid="sidebar-shell")
assert html =~ ~s(style="width: 460px;")
end
test "sidebar filters and load more are server-driven", %{project: project} do
seed_sidebar_posts(project.id)
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "tech", color: "#112233"})
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-search-form")
assert html =~ ~s(data-testid="sidebar-filter-toggle")
assert html =~ ~s(class="sidebar-section-header")
assert html =~ ~s(class="sidebar-actions")
assert html =~ ~s(data-testid="sidebar-load-more")
assert html_position(html, ~s(data-testid="sidebar-load-more")) > html_position(html, ">Archived<")
refute html =~ ~s(data-testid="sidebar-filter-tag")
assert html =~ "Alpha Post"
refute html =~ "Overflow Post"
html =
view
|> element("[data-testid='sidebar-filter-toggle']")
|> render_click()
assert html =~ ~s(class="calendar-header collapsible-header collapsed")
assert html =~ ~s(class="filter-header collapsible-header collapsed")
refute html =~ ~s(class="calendar-year-header")
refute html =~ ~s(data-testid="sidebar-filter-tag")
html =
view
|> element("[data-testid='sidebar-filter-tags-header']")
|> render_click()
assert html =~ ~s(class="filter-chip has-color")
assert html =~ ~s(data-testid="sidebar-filter-tag")
html =
view
|> form("[data-testid='sidebar-search-form']", %{sidebar_filters: %{search: "Alpha"}})
|> render_change()
assert html =~ "Alpha Post"
refute html =~ ~s(data-open-title="Beta Post")
html =
view
|> element("[data-testid='sidebar-clear-search']")
|> render_click()
assert html =~ "Beta Post"
html =
view
|> element("[data-testid='sidebar-filter-tag'][data-filter-tag='tech']")
|> render_click()
assert html =~ "Alpha Post"
refute html =~ ~s(data-open-title="Beta Post")
html =
view
|> element("[data-testid='sidebar-clear-filters']")
|> render_click()
assert html =~ "Beta Post"
html =
view
|> element("[data-testid='sidebar-load-more']")
|> render_click()
assert html =~ "Overflow Post"
end
test "project switcher, ui language, dashboard recents, and output log are wired", %{temp_dir: temp_dir} do
{:ok, other_project} = Projects.create_project(%{name: "Second Blog", data_path: Path.join(temp_dir, "second")})
{:ok, recent_post} = Posts.create_post(%{project_id: other_project.id, title: "Recent Shell Post", content: "body"})
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ "Shell Project"
refute html =~ "Second Blog"
html =
view
|> element("[data-testid='project-selector-trigger']")
|> render_click()
assert html =~ ~s(data-testid="project-dropdown")
assert html =~ "Second Blog"
html =
view
|> element("[data-testid='project-item'][data-project-id='#{other_project.id}']")
|> render_click()
assert html =~ "Second Blog"
html =
view
|> form("[data-testid='status-language-form']", %{ui_language: "de"})
|> render_change()
assert html =~ "Beiträge durchsuchen..."
html =
view
|> element("[data-testid='recent-post-item'][data-post-id='#{recent_post.id}']")
|> render_click()
assert html =~ ~s(data-tab-type="post")
assert html =~ ~s(data-tab-id="#{recent_post.id}")
assert html =~ "Recent Shell Post"
html =
render_click(view, "select_panel_tab", %{"tab" => "output"})
assert html =~ "Activated Second Blog"
end
test "task button opens tasks and post panels render real link and git data", %{project: project, temp_dir: temp_dir} do
{:ok, target} = Posts.create_post(%{project_id: project.id, title: "Target Post", content: "target body"})
{:ok, target} = Posts.publish_post(target.id)
target_href = canonical_post_href(target)
{:ok, source} =
Posts.create_post(%{project_id: project.id, title: "Linking Source", content: "See [Target](#{target_href})"})
{:ok, source} = Posts.publish_post(source.id)
:ok = Posts.rebuild_post_links(project.id)
init_git_repo!(temp_dir, "Add published posts")
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => target.id,
"title" => "Target Post",
"subtitle" => "published"
})
assert html =~ "Target Post"
html = render_click(view, "select_panel_tab", %{"tab" => "post_links"})
assert html =~ "Backlinks"
assert html =~ source.title
html = render_click(view, "select_panel_tab", %{"tab" => "git_log"})
assert html =~ "Add published posts"
html = render_click(view, "select_panel_tab", %{"tab" => "output"})
refute html =~ ~s(class="panel-shell is-hidden")
html =
view
|> element("[data-testid='status-task-button']")
|> render_click()
refute html =~ ~s(class="panel-shell is-hidden")
assert html =~ ~s(<button class="panel-tab active" type="button" phx-click="select_panel_tab" phx-value-tab="tasks">)
assert html =~ ~s(class="task-list") or html =~ "No background tasks running"
end
test "post tabs render a real editor and drive save publish discard flows", %{project: project} do
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "alpha", color: "#112233"})
assert {:ok, _tag} = Tags.create_tag(%{project_id: project.id, name: "beta", color: "#445566"})
assert {:ok, _metadata} = Metadata.add_category(project.id, "notes")
assert {:ok, _metadata} = Metadata.add_category(project.id, "guides")
{:ok, post} =
Posts.create_post(%{
project_id: project.id,
title: "Draft Shell Post",
content: "Initial body",
excerpt: "Initial excerpt",
tags: ["alpha", "beta"],
categories: ["notes", "guides"]
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => "draft"
})
assert html =~ ~s(data-testid="post-editor")
assert html =~ ~s(data-testid="post-editor-form")
assert html =~ ~s(name="post_editor[title]")
assert html =~ ~s(name="post_editor[content]")
assert html =~ ~s(name="post_editor[excerpt]")
assert html =~ ~s(data-testid="post-publish-button")
assert html =~ ~s(data-testid="post-discard-button")
assert html =~ ~s(data-testid="post-detect-language-button")
assert html =~ "quick-actions-wrapper"
assert html =~ "quick-actions-btn"
assert html =~ "editor-header"
assert html =~ "editor-content"
assert html =~ "metadata-toggle-header"
assert html =~ "editor-translations-flags"
assert html =~ "editor-header-row"
assert html =~ "editor-media-panel"
assert html =~ "editor-body"
assert html =~ "editor-toolbar"
assert html =~ "editor-footer"
assert html =~ "tag-input-container"
assert html =~ "tag-chip"
assert html =~ "alpha"
assert html =~ "beta"
assert html =~ "notes"
assert html =~ "guides"
refute html =~ ~s(phx-click="save_post_editor")
refute html =~ ~s(data-testid="post-delete-button")
refute html =~ "gallery-button"
refute html =~ "Desktop workbench content routed through the Elixir shell."
html = render_click(view, "toggle_post_editor_quick_actions", %{"id" => post.id})
assert html =~ "quick-actions-menu"
assert html =~ "quick-action-item"
assert html =~ "quick-actions-divider"
html = render_click(view, "set_post_editor_mode", %{"id" => post.id, "mode" => "preview"})
assert html =~ ~s(data-testid="post-editor-preview")
assert html =~ "editor-preview-frame"
refute html =~ ~s(data-testid="post-editor-content")
html = render_click(view, "set_post_editor_mode", %{"id" => post.id, "mode" => "markdown"})
assert html =~ ~s(data-testid="post-editor-content")
html =
view
|> form("[data-testid='post-editor-form']", %{
post_editor: %{
title: "Updated Shell Post",
content: "Updated body",
excerpt: "Updated excerpt",
tags: "alpha, beta",
categories: "notes, guides",
author: "Ada Lovelace",
language: "de",
do_not_translate: "false"
}
})
|> render_change()
assert html =~ ~s(class="tab active dirty")
assert html =~ "Updated Shell Post"
_html = render_click(view, "save_post_editor", %{"id" => post.id})
saved_post = Posts.get_post!(post.id)
assert saved_post.title == "Updated Shell Post"
assert saved_post.content == "Updated body"
assert saved_post.excerpt == "Updated excerpt"
assert saved_post.tags == ["alpha", "beta"]
assert saved_post.categories == ["notes", "guides"]
assert saved_post.author == "Ada Lovelace"
assert saved_post.language == "de"
html = render_click(view, "publish_post_editor", %{"id" => post.id})
assert html =~ ~s(data-testid="post-status-badge")
assert html =~ ~s(data-testid="post-delete-button")
refute html =~ ~s(data-testid="post-publish-button")
refute html =~ ~s(data-testid="post-discard-button")
assert Posts.get_post!(post.id).status == :published
_html =
view
|> form("[data-testid='post-editor-form']", %{
post_editor: %{
title: "Published Shell Post",
content: "Draft changes after publish",
excerpt: "Changed after publish",
tags: "alpha, beta",
categories: "notes, guides",
author: "Ada Lovelace",
language: "de",
do_not_translate: "false"
}
})
|> render_change()
_html = render_click(view, "save_post_editor", %{"id" => post.id})
assert Posts.get_post!(post.id).status == :draft
html = render_click(view, "discard_post_editor", %{"id" => post.id})
discarded_post = Posts.get_post!(post.id)
assert html =~ "Updated Shell Post"
assert discarded_post.status == :published
assert discarded_post.content == nil
assert discarded_post.title == "Updated Shell Post"
end
defp seed_sidebar_posts(project_id) do
now = Persistence.now_ms()
entries =
[
sidebar_post(project_id, "alpha-post", "Alpha Post", now + 3_000, ["tech"], ["notes"]),
sidebar_post(project_id, "beta-post", "Beta Post", now + 2_000, ["design"], ["guides"])
] ++
Enum.map(1..498, fn index ->
sidebar_post(project_id, "filler-#{index}", "Filler #{index}", now - index, ["filler"], ["archive"])
end) ++
[sidebar_post(project_id, "overflow-post", "Overflow Post", now - 10_000, ["tech"], ["notes"])]
{count, _rows} = Repo.insert_all(Post, entries)
assert count == length(entries)
end
defp html_position(html, needle) do
case :binary.match(html, needle) do
{index, _length} -> index
:nomatch -> -1
end
end
defp sidebar_post(project_id, slug, title, timestamp, tags, categories) do
%{
id: Ecto.UUID.generate(),
project_id: project_id,
title: title,
slug: slug,
excerpt: nil,
content: nil,
status: :published,
author: nil,
created_at: timestamp,
updated_at: timestamp,
published_at: timestamp,
file_path: "posts/#{slug}.md",
checksum: nil,
tags: tags,
categories: categories,
template_slug: nil,
language: "en",
do_not_translate: false,
published_title: nil,
published_content: nil,
published_tags: nil,
published_categories: nil,
published_excerpt: nil
}
end
defp canonical_post_href(post) do
datetime = DateTime.from_unix!(post.created_at, :millisecond)
Path.join([
"",
Integer.to_string(datetime.year),
String.pad_leading(Integer.to_string(datetime.month), 2, "0"),
String.pad_leading(Integer.to_string(datetime.day), 2, "0"),
post.slug,
""
])
end
defp init_git_repo!(project_dir, message) do
run_git!(project_dir, ["init", "-b", "master"])
run_git!(project_dir, ["config", "user.name", "bDS Tests"])
run_git!(project_dir, ["config", "user.email", "tests@example.com"])
run_git!(project_dir, ["add", "-A"])
run_git!(project_dir, ["commit", "-m", message])
end
defp run_git!(dir, args) do
{output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)
assert status == 0, output
end
end