1014 lines
33 KiB
Rust
1014 lines
33 KiB
Rust
// tests/m8_7_acceptance.rs --- T M8.7 magit-class gestures.
|
|
|
|
//! Acceptance for the M8.7 deliverable: stage / unstage / commit /
|
|
//! push / branch operations as bindings on the magit buffer that
|
|
//! act on the section / item under cursor. Multi-step gestures
|
|
//! compose with `pmacs.minibuffer.read`.
|
|
//!
|
|
//! Spec acceptance bullets:
|
|
//!
|
|
//! 1. Stage / unstage / commit work end-to-end on a real repo.
|
|
//! 2. Push to a configured remote works; failure produces a
|
|
//! readable error with the underlying Git output.
|
|
//! 3. Branch creation and switching work.
|
|
//! 4. All gestures are introspectable via `describe-key` (per the
|
|
//! M2 contract).
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use pmacs::editor::EditorState;
|
|
use pmacs::frontend::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
|
use pmacs::lua_bindings::PackageInstallOverride;
|
|
use pmacs::protocol::FrontendId;
|
|
use tempfile::TempDir;
|
|
|
|
fn plain_key(code: KeyCode) -> KeyEvent {
|
|
KeyEvent {
|
|
code,
|
|
modifiers: KeyModifiers::NONE,
|
|
kind: KeyEventKind::Press,
|
|
state: crossterm::event::KeyEventState::NONE,
|
|
}
|
|
}
|
|
|
|
fn magit_package_path() -> PathBuf {
|
|
let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
here.join("tests").join("fixtures").join("pmacs-magit")
|
|
}
|
|
|
|
fn pump_until<F: Fn(&EditorState) -> bool>(state: &mut EditorState, predicate: F) {
|
|
pump_until_with_deadline(state, predicate, Duration::from_secs(5));
|
|
}
|
|
|
|
fn pump_until_with_deadline<F: Fn(&EditorState) -> bool>(
|
|
state: &mut EditorState,
|
|
predicate: F,
|
|
timeout: Duration,
|
|
) {
|
|
let deadline = Instant::now() + timeout;
|
|
while !predicate(state) {
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"async pump deadline exceeded after {timeout:?}"
|
|
);
|
|
state.tick_async();
|
|
state.tick_processes();
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
}
|
|
}
|
|
|
|
fn editor_with_magit() -> (EditorState, TempDir, TempDir) {
|
|
let cache = tempfile::tempdir().expect("cache tempdir");
|
|
let user_root = tempfile::tempdir().expect("user-root tempdir");
|
|
let mut state = EditorState::new();
|
|
state.lua_host.reopen_init_phase_for_testing();
|
|
state.lua_host.set_package_install_override(
|
|
PackageInstallOverride::new()
|
|
.with_cache_dir(cache.path().to_path_buf())
|
|
.with_user_install_root(user_root.path().to_path_buf()),
|
|
);
|
|
let pkg = magit_package_path();
|
|
let pkg_str = pkg.display().to_string();
|
|
let install = format!(
|
|
r#"
|
|
pmacs.packages.install_local("{pkg_str}")
|
|
require("pmacs-magit")
|
|
"#
|
|
);
|
|
state
|
|
.lua_host
|
|
.eval(Some("magit-install"), &install)
|
|
.unwrap_or_else(|e| panic!("install_local + require failed: {e}"));
|
|
(state, cache, user_root)
|
|
}
|
|
|
|
fn make_test_repo() -> TempDir {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
git_run(dir.path(), &["init", "-b", "main"]);
|
|
git_run(dir.path(), &["config", "user.email", "test@example.com"]);
|
|
git_run(dir.path(), &["config", "user.name", "Test User"]);
|
|
std::fs::write(dir.path().join("README.md"), b"hello\n").expect("write README");
|
|
git_run(dir.path(), &["add", "README.md"]);
|
|
git_run(dir.path(), &["commit", "-m", "initial commit"]);
|
|
dir
|
|
}
|
|
|
|
fn git_run(cwd: &Path, args: &[&str]) {
|
|
let status = Command::new("git")
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.status()
|
|
.unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
|
|
assert!(status.success(), "git {args:?} returned {status}");
|
|
}
|
|
|
|
fn git_capture(cwd: &Path, args: &[&str]) -> String {
|
|
let out = Command::new("git")
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
|
|
assert!(
|
|
out.status.success(),
|
|
"git {args:?} returned {}: {}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
String::from_utf8_lossy(&out.stdout).into_owned()
|
|
}
|
|
|
|
fn open_status(state: &mut EditorState, repo_root: &Path) {
|
|
let chunk = format!(
|
|
r#"
|
|
_G.M8_7_OPEN_DONE = nil
|
|
pmacs.async(function()
|
|
require("pmacs-magit").open_status({path:?})
|
|
_G.M8_7_OPEN_DONE = true
|
|
end)
|
|
"#,
|
|
path = repo_root.display().to_string()
|
|
);
|
|
state
|
|
.lua_host
|
|
.eval(Some("open_status"), &chunk)
|
|
.unwrap_or_else(|e| panic!("open_status eval failed: {e}"));
|
|
pump_until(state, |s| {
|
|
s.lua_host
|
|
.lua()
|
|
.globals()
|
|
.get::<bool>("M8_7_OPEN_DONE")
|
|
.unwrap_or(false)
|
|
});
|
|
}
|
|
|
|
fn pump_until_refresh_settled(state: &mut EditorState) {
|
|
pump_until(state, |s| {
|
|
s.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
local te = require('pmacs-magit').__pmacs_magit_test_seam_DO_NOT_USE
|
|
local h = te.active_handle()
|
|
return h ~= nil and h.refresh_pending == false
|
|
",
|
|
)
|
|
.eval::<bool>()
|
|
.unwrap_or(false)
|
|
});
|
|
}
|
|
|
|
fn active_buffer_text(state: &mut EditorState) -> String {
|
|
state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
local buf = pmacs.window.buffer()
|
|
return buf:slice(0, buf:len())
|
|
",
|
|
)
|
|
.eval::<String>()
|
|
.expect("read active buffer")
|
|
}
|
|
|
|
/// Move cursor to the line whose text contains `needle`. Walks the
|
|
/// buffer top-to-bottom and stops at the first match. Asserts on
|
|
/// not-found.
|
|
fn move_cursor_to_line_containing(state: &mut EditorState, needle: &str) {
|
|
let target_line: i64 = state
|
|
.lua_host
|
|
.lua()
|
|
.load(format!(
|
|
r#"
|
|
local buf = pmacs.window.buffer()
|
|
local text = buf:slice(0, buf:len())
|
|
local line = 0
|
|
local pos = 1
|
|
while pos <= #text + 1 do
|
|
local nl = text:find("\n", pos, true)
|
|
local end_pos = nl or (#text + 1)
|
|
local segment = text:sub(pos, end_pos - 1)
|
|
if segment:find({needle:?}, 1, true) ~= nil then
|
|
return line
|
|
end
|
|
if nl == nil then return -1 end
|
|
pos = nl + 1
|
|
line = line + 1
|
|
end
|
|
return -1
|
|
"#
|
|
))
|
|
.eval()
|
|
.expect("find line eval");
|
|
assert!(
|
|
target_line >= 0,
|
|
"buffer does not contain line with {needle:?}"
|
|
);
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("move-to-target"),
|
|
&format!("for _ = 1, {target_line} do pmacs.editor.move_down() end"),
|
|
)
|
|
.expect("move-to-target");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bullet 1 --- stage / unstage / commit work end-to-end
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn magit_stage_gesture_moves_file_from_working_to_staged() {
|
|
let repo = make_test_repo();
|
|
std::fs::write(repo.path().join("README.md"), b"hello\nworld\n").expect("modify README");
|
|
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
let before = active_buffer_text(&mut state);
|
|
assert!(
|
|
before.contains("Working tree changes (1)"),
|
|
"before stage: README.md should be in working: {before}"
|
|
);
|
|
assert!(
|
|
before.contains("Staged changes (0)"),
|
|
"before stage: nothing staged: {before}"
|
|
);
|
|
|
|
move_cursor_to_line_containing(&mut state, "README.md");
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-stage"),
|
|
r#"pmacs.command.invoke("pmacs-magit.stage")"#,
|
|
)
|
|
.expect("invoke stage");
|
|
pump_until_refresh_settled(&mut state);
|
|
|
|
let after = active_buffer_text(&mut state);
|
|
assert!(
|
|
after.contains("Working tree changes (0)"),
|
|
"after stage: working should be empty: {after}"
|
|
);
|
|
assert!(
|
|
after.contains("Staged changes (1)"),
|
|
"after stage: README.md should be staged: {after}"
|
|
);
|
|
// Disk-side check: porcelain shows X=M.
|
|
let porcelain = git_capture(repo.path(), &["status", "--porcelain=v2"]);
|
|
assert!(
|
|
porcelain.contains("1 M"),
|
|
"git status should show staged file: {porcelain}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn magit_unstage_gesture_moves_file_from_staged_to_working() {
|
|
let repo = make_test_repo();
|
|
std::fs::write(repo.path().join("README.md"), b"hello\nworld\n").expect("modify");
|
|
git_run(repo.path(), &["add", "README.md"]);
|
|
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
let before = active_buffer_text(&mut state);
|
|
assert!(before.contains("Staged changes (1)"));
|
|
|
|
move_cursor_to_line_containing(&mut state, "README.md");
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-unstage"),
|
|
r#"pmacs.command.invoke("pmacs-magit.unstage")"#,
|
|
)
|
|
.expect("invoke unstage");
|
|
pump_until_refresh_settled(&mut state);
|
|
|
|
let after = active_buffer_text(&mut state);
|
|
assert!(
|
|
after.contains("Working tree changes (1)"),
|
|
"after unstage: README.md back in working: {after}"
|
|
);
|
|
assert!(
|
|
after.contains("Staged changes (0)"),
|
|
"after unstage: staged is empty: {after}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn magit_commit_gesture_opens_commit_message_buffer_and_creates_commit() {
|
|
let repo = make_test_repo();
|
|
std::fs::write(repo.path().join("README.md"), b"hello\nworld\n").expect("modify");
|
|
git_run(repo.path(), &["add", "README.md"]);
|
|
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
// Invoke commit gesture -> opens *magit-commit* buffer (not a
|
|
// minibuffer prompt, per M8.7's "composes a message buffer"
|
|
// requirement).
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-commit"),
|
|
r#"pmacs.command.invoke("pmacs-magit.commit")"#,
|
|
)
|
|
.expect("invoke commit");
|
|
|
|
// Active buffer should now be *magit-commit*; minibuffer is NOT active.
|
|
let active_name: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
local id = pmacs.window.buffer()
|
|
local d = pmacs.describe.buffer(id)
|
|
return d and d.name or '<nil>'
|
|
",
|
|
)
|
|
.eval()
|
|
.expect("active buffer name");
|
|
assert_eq!(active_name, "*magit-commit*");
|
|
let mb_active: bool = state
|
|
.lua_host
|
|
.lua()
|
|
.load("return pmacs.minibuffer.is_active()")
|
|
.eval()
|
|
.expect("mb active");
|
|
assert!(!mb_active, "commit must use a buffer, not the minibuffer");
|
|
|
|
// Type a multiline message into the buffer (subject + body).
|
|
// The package's scaffold is "<empty subject line>\n\n# comment lines\n".
|
|
// We replace the first line with "subject" and add a body.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("type-message"),
|
|
r#"
|
|
local buf = pmacs.window.buffer()
|
|
local existing = buf:slice(0, buf:len())
|
|
local body = "subject line\n\nbody paragraph one.\nbody paragraph two.\n\n" ..
|
|
existing:gsub("^[^#]*", "")
|
|
buf:replace(0, buf:len(), body)
|
|
"#,
|
|
)
|
|
.expect("type message");
|
|
|
|
// Submit via the C-c C-c command (binding goes through the
|
|
// dispatch path in real use; we invoke directly here to keep
|
|
// the test focused on the buffer flow, then have a separate
|
|
// test exercise the dispatch path).
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("submit"),
|
|
r#"pmacs.command.invoke("pmacs-magit.commit-submit")"#,
|
|
)
|
|
.expect("submit");
|
|
pump_until_refresh_settled(&mut state);
|
|
|
|
// Disk-side: commit landed with multiline message.
|
|
let log = git_capture(repo.path(), &["log", "-1", "--format=%B"]);
|
|
assert!(
|
|
log.contains("subject line") && log.contains("body paragraph one"),
|
|
"git log -1 must include the multiline message: {log}"
|
|
);
|
|
// Comment lines should NOT be in the message.
|
|
assert!(
|
|
!log.contains("# Please enter") && !log.contains("# Branch:"),
|
|
"comment lines must be stripped from the commit message: {log}"
|
|
);
|
|
// Buffer reflects the new commit.
|
|
let after = active_buffer_text(&mut state);
|
|
assert!(
|
|
after.contains("subject line"),
|
|
"magit buffer should show new commit: {after}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn magit_commit_buffer_cancel_closes_buffer_without_committing() {
|
|
let repo = make_test_repo();
|
|
std::fs::write(repo.path().join("README.md"), b"hello\nworld\n").expect("modify");
|
|
git_run(repo.path(), &["add", "README.md"]);
|
|
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
let head_before = git_capture(repo.path(), &["rev-parse", "HEAD"]);
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-commit"),
|
|
r#"pmacs.command.invoke("pmacs-magit.commit")"#,
|
|
)
|
|
.expect("invoke");
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("cancel"),
|
|
r#"pmacs.command.invoke("pmacs-magit.commit-cancel")"#,
|
|
)
|
|
.expect("cancel");
|
|
|
|
// Active buffer is back to the magit buffer.
|
|
let active_name: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
local id = pmacs.window.buffer()
|
|
local d = pmacs.describe.buffer(id)
|
|
return d and d.name or '<nil>'
|
|
",
|
|
)
|
|
.eval()
|
|
.expect("active buffer name");
|
|
assert!(
|
|
active_name.starts_with("*magit:"),
|
|
"after cancel, active buffer must be the magit buffer; got: {active_name}"
|
|
);
|
|
// HEAD unchanged.
|
|
let head_after = git_capture(repo.path(), &["rev-parse", "HEAD"]);
|
|
assert_eq!(head_before, head_after);
|
|
}
|
|
|
|
#[test]
|
|
fn magit_commit_buffer_c_c_c_c_dispatch_submits() {
|
|
let repo = make_test_repo();
|
|
std::fs::write(repo.path().join("README.md"), b"hello\nworld\n").expect("modify");
|
|
git_run(repo.path(), &["add", "README.md"]);
|
|
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("open-commit-buffer"),
|
|
r#"pmacs.command.invoke("pmacs-magit.commit")"#,
|
|
)
|
|
.expect("open");
|
|
// Set a non-empty message.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("set-msg"),
|
|
r#"
|
|
local buf = pmacs.window.buffer()
|
|
buf:replace(0, buf:len(), "via-c-c-c-c-dispatch\n")
|
|
"#,
|
|
)
|
|
.expect("set");
|
|
|
|
// Dispatch C-c C-c through the actual key path. This pins the
|
|
// buffer-local multi-key binding works through dispatch (per
|
|
// the auto-memory feedback that command.invoke bypasses dispatch
|
|
// and dead bindings would silently pass).
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent {
|
|
code: KeyCode::Char('c'),
|
|
modifiers: KeyModifiers::CONTROL,
|
|
kind: KeyEventKind::Press,
|
|
state: crossterm::event::KeyEventState::NONE,
|
|
},
|
|
);
|
|
state.dispatch_key(
|
|
FrontendId::LOCAL,
|
|
KeyEvent {
|
|
code: KeyCode::Char('c'),
|
|
modifiers: KeyModifiers::CONTROL,
|
|
kind: KeyEventKind::Press,
|
|
state: crossterm::event::KeyEventState::NONE,
|
|
},
|
|
);
|
|
pump_until_refresh_settled(&mut state);
|
|
|
|
let log = git_capture(repo.path(), &["log", "-1", "--format=%s"]);
|
|
assert!(
|
|
log.contains("via-c-c-c-c-dispatch"),
|
|
"C-c C-c dispatch must submit the commit: {log}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bullet 2 --- push to a configured remote
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn magit_push_gesture_pushes_to_configured_remote() {
|
|
// Set up a bare-repo "remote" alongside the working repo and add
|
|
// it as origin.
|
|
let remote_dir = tempfile::tempdir().expect("remote tempdir");
|
|
git_run(remote_dir.path(), &["init", "--bare", "-b", "main"]);
|
|
|
|
let repo = make_test_repo();
|
|
// Pre-test setup is *only* "git remote add origin <url>". The
|
|
// gesture must work on first push without manual `-u origin main`
|
|
// priming --- per the M8.8 audit finding 3 fix, the gesture
|
|
// passes `-u <remote> HEAD` so first-push sets upstream tracking
|
|
// automatically.
|
|
git_run(
|
|
repo.path(),
|
|
&[
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
&remote_dir.path().display().to_string(),
|
|
],
|
|
);
|
|
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
// Invoke push -> opens minibuffer with "origin" as initial.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-push"),
|
|
r#"pmacs.command.invoke("pmacs-magit.push")"#,
|
|
)
|
|
.expect("invoke push");
|
|
// Accept the default ("origin" was the initial value).
|
|
let initial: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load("return pmacs.minibuffer.contents()")
|
|
.eval()
|
|
.expect("mb contents");
|
|
assert_eq!(initial, "origin", "minibuffer initial must be 'origin'");
|
|
state
|
|
.lua_host
|
|
.eval(Some("accept-push"), r"pmacs.minibuffer.accept()")
|
|
.expect("accept push");
|
|
pump_until_refresh_settled(&mut state);
|
|
|
|
// The remote bare repo now has the initial commit (we didn't
|
|
// pre-prime upstream, so this is genuinely the first push).
|
|
let remote_log = git_capture(remote_dir.path(), &["log", "--oneline", "-n", "5"]);
|
|
assert!(
|
|
remote_log.contains("initial commit"),
|
|
"remote should have the pushed commit on first push without manual `-u` priming: {remote_log}"
|
|
);
|
|
// Upstream tracking should now be configured (the `-u` flag did its job).
|
|
let upstream = git_capture(repo.path(), &["rev-parse", "--abbrev-ref", "main@{u}"]);
|
|
assert_eq!(
|
|
upstream.trim(),
|
|
"origin/main",
|
|
"first push should set upstream tracking via -u"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn magit_push_failure_surfaces_error() {
|
|
let repo = make_test_repo();
|
|
// No remote configured; push will fail.
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-push-fail"),
|
|
r#"pmacs.command.invoke("pmacs-magit.push")"#,
|
|
)
|
|
.expect("invoke push");
|
|
state
|
|
.lua_host
|
|
.eval(Some("accept-default"), "pmacs.minibuffer.accept()")
|
|
.expect("accept");
|
|
pump_until_refresh_settled(&mut state);
|
|
|
|
// The package shouldn't crash; the buffer should still show its
|
|
// previous state.
|
|
let after = active_buffer_text(&mut state);
|
|
assert!(
|
|
after.contains("initial commit"),
|
|
"buffer should still be valid after a failed push: {after}"
|
|
);
|
|
|
|
// The M8.7 spec requires "failure produces a readable error
|
|
// with the underlying Git output" --- that's
|
|
// pmacs.editor.set_status's job in run_and_refresh. Read it via
|
|
// the editor core (no public getter for the status line in v0.1;
|
|
// pmacs.editor exposes set_status but not get_status, so tests
|
|
// reach into the SharedCore the same way they do for disk-side
|
|
// assertions).
|
|
let status_line = state.core.borrow().status.clone();
|
|
assert!(
|
|
status_line.contains("pmacs-magit.push: git failed"),
|
|
"modeline must surface the failure with the gesture's name + 'git failed' prefix; got: {status_line:?}"
|
|
);
|
|
// Underlying Git output: with no remote configured, git's stderr
|
|
// typically begins with "fatal:" --- e.g.,
|
|
// "fatal: 'origin' does not appear to be a git repository"
|
|
// We don't pin the exact wording (git's message text changes
|
|
// across versions), but the "fatal:" marker is stable across
|
|
// git versions for this failure class.
|
|
assert!(
|
|
status_line.contains("fatal:"),
|
|
"modeline must include git's stderr (recognizable by 'fatal:' prefix); got: {status_line:?}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bullet 3 --- branch creation and switching
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn magit_branch_create_gesture_creates_and_checks_out_new_branch() {
|
|
let repo = make_test_repo();
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-branch-create"),
|
|
r#"pmacs.command.invoke("pmacs-magit.branch-create")"#,
|
|
)
|
|
.expect("invoke");
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("type-name"),
|
|
r#"
|
|
pmacs.minibuffer.set_contents("topic/m8.7-test")
|
|
pmacs.minibuffer.accept()
|
|
"#,
|
|
)
|
|
.expect("set + accept");
|
|
pump_until_refresh_settled(&mut state);
|
|
|
|
// Disk-side: branch exists and is current.
|
|
let branches = git_capture(repo.path(), &["branch", "--list"]);
|
|
assert!(
|
|
branches.contains("* topic/m8.7-test"),
|
|
"new branch must exist and be current: {branches}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn magit_branch_switch_gesture_switches_to_existing_branch() {
|
|
let repo = make_test_repo();
|
|
git_run(repo.path(), &["branch", "feature"]);
|
|
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-branch-switch"),
|
|
r#"pmacs.command.invoke("pmacs-magit.branch-switch")"#,
|
|
)
|
|
.expect("invoke");
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("set-target"),
|
|
r#"
|
|
pmacs.minibuffer.set_contents("feature")
|
|
pmacs.minibuffer.accept()
|
|
"#,
|
|
)
|
|
.expect("set + accept");
|
|
pump_until_refresh_settled(&mut state);
|
|
|
|
let branches = git_capture(repo.path(), &["branch", "--list"]);
|
|
assert!(
|
|
branches.contains("* feature"),
|
|
"feature must be current branch after switch: {branches}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn magit_branch_switch_minibuffer_offers_existing_branches_as_candidates() {
|
|
let repo = make_test_repo();
|
|
git_run(repo.path(), &["branch", "feature"]);
|
|
git_run(repo.path(), &["branch", "release"]);
|
|
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-branch-switch"),
|
|
r#"pmacs.command.invoke("pmacs-magit.branch-switch")"#,
|
|
)
|
|
.expect("invoke");
|
|
let candidates: Vec<String> = state
|
|
.lua_host
|
|
.lua()
|
|
.load("return pmacs.minibuffer.candidates()")
|
|
.eval()
|
|
.expect("candidates");
|
|
// The branches snapshot is { "feature", "main", "release" }
|
|
// (encounter order from `git branch --list`). All three should
|
|
// be in the minibuffer candidate list.
|
|
for branch in ["feature", "main", "release"] {
|
|
assert!(
|
|
candidates.iter().any(|c| c == branch),
|
|
"branch {branch} must be in candidates: {candidates:?}"
|
|
);
|
|
}
|
|
|
|
// Cancel the prompt rather than accept a selection.
|
|
state
|
|
.lua_host
|
|
.eval(Some("cancel"), "pmacs.minibuffer.cancel()")
|
|
.expect("cancel");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bullet 4 --- describe-key introspection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn magit_gestures_are_introspectable_via_describe() {
|
|
let (state, _c, _u) = editor_with_magit();
|
|
let lua = state.lua_host.lua();
|
|
// Per the M2 contract, every defined command has a name and
|
|
// description retrievable via `pmacs.describe.command(name)`.
|
|
// Each gesture must be present and carry a non-empty description.
|
|
for name in [
|
|
"pmacs-magit.stage",
|
|
"pmacs-magit.unstage",
|
|
"pmacs-magit.commit",
|
|
"pmacs-magit.commit-submit",
|
|
"pmacs-magit.commit-cancel",
|
|
"pmacs-magit.push",
|
|
"pmacs-magit.branch-create",
|
|
"pmacs-magit.branch-switch",
|
|
] {
|
|
let desc: String = lua
|
|
.load(format!(
|
|
r#"
|
|
local info = pmacs.describe.command({name:?})
|
|
if info == nil then return "<missing>" end
|
|
return info.description or ""
|
|
"#
|
|
))
|
|
.eval()
|
|
.unwrap_or_else(|e| panic!("describe.command({name}) failed: {e}"));
|
|
assert!(
|
|
!desc.is_empty() && desc != "<missing>",
|
|
"command {name} must be defined with a non-empty description; got {desc:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Regression: cursor-on-irrelevant-section gestures are no-ops
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn magit_stage_on_recent_commits_section_is_a_clear_no_op() {
|
|
let repo = make_test_repo();
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
// Cursor lands on byte 0 (Section A header). Move to a Recent
|
|
// commits line.
|
|
move_cursor_to_line_containing(&mut state, "initial commit");
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-stage-noop"),
|
|
r#"pmacs.command.invoke("pmacs-magit.stage")"#,
|
|
)
|
|
.expect("invoke");
|
|
// Pump a moment to let any async errors surface.
|
|
for _ in 0..5 {
|
|
state.tick_async();
|
|
state.tick_processes();
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
}
|
|
// Buffer is unchanged.
|
|
let after = active_buffer_text(&mut state);
|
|
assert!(
|
|
after.contains("Recent commits (1)"),
|
|
"log section unchanged: {after}"
|
|
);
|
|
assert!(
|
|
after.contains("Working tree changes (0)"),
|
|
"working unchanged: {after}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Regression: empty commit message (only comments) is rejected
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The buffer-flow's submit path strips comment lines and trailing
|
|
// blanks before checking emptiness; a buffer with only the scaffold
|
|
// comment lines (no real subject) must NOT fire git commit.
|
|
|
|
#[test]
|
|
fn magit_commit_buffer_with_only_comments_is_rejected() {
|
|
let repo = make_test_repo();
|
|
std::fs::write(repo.path().join("README.md"), b"hello\nworld\n").expect("modify");
|
|
git_run(repo.path(), &["add", "README.md"]);
|
|
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
let head_before = git_capture(repo.path(), &["rev-parse", "HEAD"]);
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-commit"),
|
|
r#"pmacs.command.invoke("pmacs-magit.commit")"#,
|
|
)
|
|
.expect("invoke");
|
|
// Don't add any subject; the scaffold only contains comment
|
|
// lines, so submit should reject.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("submit-empty"),
|
|
r#"pmacs.command.invoke("pmacs-magit.commit-submit")"#,
|
|
)
|
|
.expect("submit");
|
|
for _ in 0..10 {
|
|
state.tick_async();
|
|
state.tick_processes();
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
let head_after = git_capture(repo.path(), &["rev-parse", "HEAD"]);
|
|
assert_eq!(
|
|
head_before, head_after,
|
|
"comment-only buffer must not commit"
|
|
);
|
|
|
|
// Active buffer is still *magit-commit* --- submit didn't tear
|
|
// it down, leaving the user a chance to add content.
|
|
let active_name: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
local id = pmacs.window.buffer()
|
|
local d = pmacs.describe.buffer(id)
|
|
return d and d.name or '<nil>'
|
|
",
|
|
)
|
|
.eval()
|
|
.expect("active name");
|
|
assert_eq!(active_name, "*magit-commit*");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// M8.8 audit in-audit fix: multi-key binding dispatch coverage
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Per the auto-memory feedback (and M8.4 audit finding 7): buffer-
|
|
// scope keybinding tests must drive `dispatch_key` --- invoking the
|
|
// command directly bypasses dispatch, so a dead binding would
|
|
// silently pass under the existing M8.7 tests. The `b c` /
|
|
// `b b` multi-key bindings are the highest-risk cases (first time
|
|
// magit-class uses multi-key sequences); pin one path so a future
|
|
// keymap-tree refactor can't break them silently.
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// M8.8 audit finding 1 fix: describe-key surfaces buffer-local bindings
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Per M2's describe-key contract, every gesture must be
|
|
// introspectable via the describe-key path. Earlier `pmacs.describe
|
|
// .key` always resolved with `active_buffer = None`, so buffer-
|
|
// local bindings (the entire magit gesture set) were invisible. The
|
|
// audit fix threads the active window's buffer through; this test
|
|
// pins that buffer-local bindings now resolve correctly via the
|
|
// public describe.key API, including for multi-key sequences.
|
|
|
|
#[test]
|
|
fn magit_describe_key_resolves_buffer_local_gesture_bindings() {
|
|
let repo = make_test_repo();
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
// For each gesture binding, describe.key on the magit buffer
|
|
// must resolve to the corresponding command. (The active
|
|
// window is the magit buffer post-open_status.)
|
|
let probes: &[(&str, &str)] = &[
|
|
("s", "pmacs-magit.stage"),
|
|
("u", "pmacs-magit.unstage"),
|
|
("c", "pmacs-magit.commit"),
|
|
("P", "pmacs-magit.push"),
|
|
("g", "pmacs-magit.refresh-status"),
|
|
("Tab", "pmacs-magit.toggle-fold"),
|
|
("b c", "pmacs-magit.branch-create"),
|
|
("b b", "pmacs-magit.branch-switch"),
|
|
];
|
|
for (sequence, expected_cmd) in probes {
|
|
let cmd: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(format!(
|
|
r#"
|
|
local info = pmacs.describe.key({sequence:?})
|
|
if info == nil then return "<unbound>" end
|
|
return info.command
|
|
"#
|
|
))
|
|
.eval()
|
|
.unwrap_or_else(|e| panic!("describe.key({sequence:?}) failed: {e}"));
|
|
assert_eq!(
|
|
cmd, *expected_cmd,
|
|
"describe.key({sequence:?}) must surface the buffer-local binding"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn magit_describe_key_resolves_commit_buffer_bindings() {
|
|
let repo = make_test_repo();
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("open-commit-buffer"),
|
|
r#"pmacs.command.invoke("pmacs-magit.commit")"#,
|
|
)
|
|
.expect("open commit buffer");
|
|
|
|
let probes: &[(&str, &str)] = &[
|
|
("C-c C-c", "pmacs-magit.commit-submit"),
|
|
("C-c C-k", "pmacs-magit.commit-cancel"),
|
|
];
|
|
for (sequence, expected_cmd) in probes {
|
|
let cmd: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(format!(
|
|
r#"
|
|
local info = pmacs.describe.key({sequence:?})
|
|
if info == nil then return "<unbound>" end
|
|
return info.command
|
|
"#
|
|
))
|
|
.eval()
|
|
.unwrap_or_else(|e| panic!("describe.key({sequence:?}) failed: {e}"));
|
|
assert_eq!(
|
|
cmd, *expected_cmd,
|
|
"describe.key({sequence:?}) must surface the commit-buffer binding"
|
|
);
|
|
}
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("cancel-commit-buffer"),
|
|
r#"pmacs.command.invoke("pmacs-magit.commit-cancel")"#,
|
|
)
|
|
.expect("cancel commit buffer");
|
|
}
|
|
|
|
#[test]
|
|
fn magit_b_c_keybinding_dispatches_to_branch_create_prompt() {
|
|
let repo = make_test_repo();
|
|
let (mut state, _c, _u) = editor_with_magit();
|
|
open_status(&mut state, repo.path());
|
|
|
|
// Press `b` then `c`. After both keys, the multi-key sequence
|
|
// resolves to "b c" -> pmacs-magit.branch-create -> opens the
|
|
// minibuffer prompt for the new branch name.
|
|
state.dispatch_key(FrontendId::LOCAL, plain_key(KeyCode::Char('b')));
|
|
state.dispatch_key(FrontendId::LOCAL, plain_key(KeyCode::Char('c')));
|
|
|
|
let prompt: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
if not pmacs.minibuffer.is_active() then return '' end
|
|
return pmacs.minibuffer.contents()
|
|
",
|
|
)
|
|
.eval()
|
|
.expect("mb state");
|
|
let active: bool = state
|
|
.lua_host
|
|
.lua()
|
|
.load("return pmacs.minibuffer.is_active()")
|
|
.eval()
|
|
.expect("mb active");
|
|
assert!(
|
|
active,
|
|
"after `b c` the branch-create prompt must be active; \
|
|
minibuffer contents was {prompt:?}"
|
|
);
|
|
// Cancel the prompt to leave editor state clean for teardown.
|
|
state
|
|
.lua_host
|
|
.eval(Some("cancel"), "pmacs.minibuffer.cancel()")
|
|
.expect("cancel");
|
|
}
|