// tests/m8_6_acceptance.rs --- T M8.6 magit-class git status integration. //! Acceptance for the M8.6 deliverable: wiring the M8.5 foldable //! section view to actual `git status` output. Spec acceptance //! bullets: //! //! 1. All five canonical sections render correctly on a typical //! repository (working tree, staged, recent commits, branches, //! stashes). //! 2. Refresh after an external commit reflects the new state //! within 500 ms. //! 3. Empty sections (no stashes, etc.) render as a one-line //! placeholder rather than disappearing. //! //! Plus regressions: //! //! * Section IDs are stable across refresh; fold-state is preserved. //! * Cursor's section is preserved across refresh. //! * Manual refresh-status command works. //! * The four parser helpers (porcelain v2, log, branches, stashes) //! round-trip canonical inputs without invoking git. //! * Non-repo path produces a refresh failure but doesn't crash the //! package. use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; use pmacs::editor::EditorState; use pmacs::lua_bindings::PackageInstallOverride; use tempfile::TempDir; fn magit_package_path() -> PathBuf { let here = PathBuf::from(env!("CARGO_MANIFEST_DIR")); here.join("tests").join("fixtures").join("pmacs-magit") } fn pump_until bool>(state: &mut EditorState, predicate: F) { pump_until_with_deadline(state, predicate, Duration::from_secs(5)); } fn pump_until_with_deadline 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:?}" ); // M8.6 runs `git` via pmacs.process.spawn; the supervisor's // events (stdout/stderr/exited) only show up if we tick // both the async runtime AND the process supervisor each // iteration --- the editor's main loop does both per pass // (editor.rs:897-898), so tests have to as well. 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_with_roots(&crate::iso::roots()); 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) } /// Build a small git repo for tests: `git init`, set user, commit a /// file. Returns the `TempDir` owning the repo. 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 open_status(state: &mut EditorState, repo_root: &Path) { let chunk = format!( r#" _G.M8_6_OPEN_DONE = nil pmacs.async(function() require("pmacs-magit").open_status({path:?}) _G.M8_6_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::("M8_6_OPEN_DONE") .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::() .expect("read active buffer") } // --------------------------------------------------------------------------- // Bullet 1 --- five canonical sections render on a typical repo // --------------------------------------------------------------------------- #[test] fn magit_status_renders_five_sections_on_typical_repo() { let repo = make_test_repo(); // Stash setup goes FIRST: `git stash push` (no paths) stashes // every change in the working tree and the index, so anything // we set up before stashing gets wiped. Stash now while the // tree is clean from make_test_repo(). std::fs::write(repo.path().join("for-stash.txt"), b"stashed content\n") .expect("write stash file"); git_run(repo.path(), &["add", "for-stash.txt"]); git_run(repo.path(), &["stash", "push", "-m", "test-stash"]); // After stash push, the tree is clean again (the for-stash.txt // change is in the stash entry). Now build the "typical" state // we want to render. std::fs::write(repo.path().join("README.md"), b"hello\nworld\n").expect("modify README"); git_run(repo.path(), &["add", "README.md"]); // staged std::fs::write(repo.path().join("README.md"), b"hello\nworld\nmore\n").expect("modify again"); // README.md now has X=M (staged) and Y=M (unstaged). std::fs::write(repo.path().join("untracked.txt"), b"new\n").expect("write untracked"); git_run(repo.path(), &["branch", "feature"]); let (mut state, _c, _u) = editor_with_magit(); open_status(&mut state, repo.path()); let text = active_buffer_text(&mut state); // All five canonical sections visible by their stable IDs (the // visible header text is "| > Title (count)"). for section_title in [ "Working tree changes", "Staged changes", "Recent commits", "Branches", "Stashes", ] { assert!( text.contains(section_title), "section {section_title:?} must render; got: {text}" ); } // The actual content surfaced. assert!( text.contains("README.md"), "modified README must show: {text}" ); assert!( text.contains("untracked.txt"), "untracked must show: {text}" ); assert!(text.contains("feature"), "feature branch must show: {text}"); assert!(text.contains("test-stash"), "stash entry must show: {text}"); assert!( text.contains("initial commit"), "initial commit must show: {text}" ); } // --------------------------------------------------------------------------- // Bullet 2 --- refresh after external commit reflects new state within 500 ms // --------------------------------------------------------------------------- #[test] fn magit_status_refreshes_after_external_commit_within_500ms() { let repo = make_test_repo(); let (mut state, _c, _u) = editor_with_magit(); open_status(&mut state, repo.path()); // Initial buffer shows "initial commit". let initial = active_buffer_text(&mut state); assert!( initial.contains("initial commit"), "initial state must include initial commit; got: {initial}" ); assert!( !initial.contains("second commit"), "second commit hasn't happened yet" ); // Externally make a new commit. std::fs::write(repo.path().join("README.md"), b"v2\n").expect("modify"); git_run(repo.path(), &["add", "README.md"]); git_run(repo.path(), &["commit", "-m", "second commit"]); // Pump until the polling loop reflects the new commit. Acceptance // budget is 500 ms; we use 750 ms to keep the test from being // flaky on slow CI while still failing if polling is broken. let started = Instant::now(); pump_until_with_deadline( &mut state, |s| { s.lua_host .lua() .load( r" local buf = pmacs.window.buffer() local text = buf:slice(0, buf:len()) return text:find('second commit', 1, true) ~= nil ", ) .eval::() .unwrap_or(false) }, Duration::from_millis(750), ); let elapsed = started.elapsed(); // Soft check on latency: the polling cadence is 250 ms, so on // a healthy run we settle in well under 500 ms. Anything past // 750 ms means the polling loop isn't running --- the deadline // above already enforces that. assert!( elapsed < Duration::from_millis(750), "refresh latency {elapsed:?} exceeded 750 ms budget" ); } // --------------------------------------------------------------------------- // Bullet 3 --- empty sections render as one-line placeholder // --------------------------------------------------------------------------- #[test] fn magit_status_empty_sections_render_as_placeholders() { // A fresh repo with one initial commit: no working-tree changes, // no staged changes, no extra branches, no stashes. Only the // "Recent commits" section has real content. let repo = make_test_repo(); let (mut state, _c, _u) = editor_with_magit(); open_status(&mut state, repo.path()); let text = active_buffer_text(&mut state); // Every empty section shows its placeholder body. assert!( text.contains("(no working-tree changes)"), "empty working tree must show placeholder: {text}" ); assert!( text.contains("(nothing staged)"), "empty staged must show placeholder: {text}" ); assert!( text.contains("(no stashes)"), "empty stashes must show placeholder: {text}" ); // Recent commits has one entry (the initial commit), so its // placeholder is NOT shown. assert!( !text.contains("(no commits yet)"), "non-empty Recent commits must not show its placeholder: {text}" ); // Branches has the implicit `main`; placeholder not shown. assert!( !text.contains("(no branches)"), "non-empty Branches must not show its placeholder: {text}" ); } // --------------------------------------------------------------------------- // Regression: section IDs stable across refresh, fold-state preserved // --------------------------------------------------------------------------- #[test] fn magit_status_fold_state_preserved_across_refresh() { let repo = make_test_repo(); let (mut state, _c, _u) = editor_with_magit(); open_status(&mut state, repo.path()); // Collapse "log" (Recent commits) section. state .lua_host .eval( Some("collapse-log"), r#" local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE local h = te.active_handle() te.fold.toggle(h.fold_state, "log") te.repaint_visible(h) "#, ) .expect("collapse log"); let collapsed = active_buffer_text(&mut state); assert!( !collapsed.contains("initial commit"), "log body must be hidden after collapse: {collapsed}" ); // Trigger a manual refresh (which produces a new spec and calls // update_spec). Fold-state should survive because `log` is a // stable ID across refreshes. state .lua_host .eval( Some("refresh"), r#"pmacs.command.invoke("pmacs-magit.refresh-status")"#, ) .expect("refresh"); pump_until(&mut 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::() .unwrap_or(false) }); let after = active_buffer_text(&mut state); assert!( !after.contains("initial commit"), "log section must remain collapsed after refresh: {after}" ); // Verify the fold_state still has "log" collapsed. let log_state: String = state .lua_host .lua() .load( r#" local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE local h = te.active_handle() return tostring(h.fold_state.log) "#, ) .eval() .expect("log fold state"); assert_eq!(log_state, "collapsed"); } // --------------------------------------------------------------------------- // Regression: cursor stays on the same section across refresh // --------------------------------------------------------------------------- #[test] fn magit_status_cursor_section_preserved_across_refresh() { let repo = make_test_repo(); let (mut state, _c, _u) = editor_with_magit(); open_status(&mut state, repo.path()); // Move cursor to Section "log" header (section_at would resolve // it back to "log"). Sections in default order are working, // staged, log, branches, stashes; each takes 2 lines (header + // 1-line placeholder body) when empty. Empty repo: working (2 // lines, header + placeholder), staged (2 lines), so log header // is at line 4. Move down 4 times. state .lua_host .eval( Some("seek-log"), r"for _ = 1, 4 do pmacs.editor.move_down() end", ) .expect("seek log"); let on_log: String = state .lua_host .lua() .load( r#" local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE local h = te.active_handle() local line = pmacs.editor.cursor_line() return tostring(te.fold.section_at(h.projection, line)) "#, ) .eval() .expect("on log"); assert_eq!(on_log, "log", "setup: cursor must be on log section"); // Manual refresh. state .lua_host .eval( Some("refresh"), r#"pmacs.command.invoke("pmacs-magit.refresh-status")"#, ) .expect("refresh"); pump_until(&mut 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::() .unwrap_or(false) }); // Cursor should still be on the log section. let after: String = state .lua_host .lua() .load( r#" local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE local h = te.active_handle() local line = pmacs.editor.cursor_line() return tostring(te.fold.section_at(h.projection, line)) "#, ) .eval() .expect("after"); assert_eq!(after, "log", "cursor must still be on log after refresh"); } // --------------------------------------------------------------------------- // Regression: refresh-status command exists + works // --------------------------------------------------------------------------- #[test] fn magit_refresh_status_command_picks_up_new_commit() { let repo = make_test_repo(); let (mut state, _c, _u) = editor_with_magit(); open_status(&mut state, repo.path()); // External change. std::fs::write(repo.path().join("README.md"), b"v2\n").expect("modify"); git_run(repo.path(), &["add", "README.md"]); git_run(repo.path(), &["commit", "-m", "manual-refresh-target"]); // Manual refresh (rather than waiting for the polling loop). state .lua_host .eval( Some("refresh-now"), r#"pmacs.command.invoke("pmacs-magit.refresh-status")"#, ) .expect("refresh-now"); pump_until(&mut state, |s| { s.lua_host .lua() .load( r" local buf = pmacs.window.buffer() local text = buf:slice(0, buf:len()) return text:find('manual-refresh-target', 1, true) ~= nil ", ) .eval::() .unwrap_or(false) }); let text = active_buffer_text(&mut state); assert!(text.contains("manual-refresh-target")); } // --------------------------------------------------------------------------- // Regression: open_status validates input // --------------------------------------------------------------------------- #[test] fn magit_open_status_rejects_non_string_repo_root() { let (state, _c, _u) = editor_with_magit(); let msg: String = state .lua_host .lua() .load( r#" local ok, err = pcall(function() require("pmacs-magit").open_status(nil) end) assert(not ok, "nil repo_root must be rejected") return tostring(err) "#, ) .eval() .expect("nil-repo eval"); assert!( msg.contains("non-empty string"), "rejection must explain why: {msg}" ); } // --------------------------------------------------------------------------- // Parser unit tests via the test seam // --------------------------------------------------------------------------- #[test] fn magit_parse_porcelain_v2_basic_inputs() { let (state, _c, _u) = editor_with_magit(); let lua = state.lua_host.lua(); // "1" lines: ordinary changed files. "2" lines: rename/copy. // "?" lines: untracked. The Lua `"# branch.head main"` literal // contains `"#`, which would close a single-hash Rust raw string; // bumping to `r##"..."##` lets the literal pass through. let json: String = lua .load( r##" local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE local input = table.concat({ "# branch.head main", "1 .M N... 100644 100644 100644 abc def README.md", "1 M. N... 100644 100644 100644 def 123 staged.txt", "? untracked.txt", }, "\n") .. "\n" local r = te.status.parse_porcelain_v2(input) return string.format( "branch=%s|staged=%d|unstaged=%d|untracked=%d|first_unstaged=%s", r.branch, #r.staged, #r.unstaged, #r.untracked, r.unstaged[1] or "(none)" ) "##, ) .eval() .expect("porcelain parse"); // "1 .M ... README.md" -> Y is M (unstaged), X is . (not staged) // "1 M. ... staged.txt" -> X is M (staged), Y is . (not unstaged) // ? untracked.txt assert_eq!( json, "branch=main|staged=1|unstaged=1|untracked=1|first_unstaged=M README.md" ); } #[test] fn magit_parse_log_basic_input() { let (state, _c, _u) = editor_with_magit(); let lua = state.lua_host.lua(); let result: String = lua .load( r#" local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE local input = "abc1234 first commit\ndef5678 second commit\n" local entries = te.status.parse_log(input) return string.format("%d|%s|%s", #entries, entries[1].hash, entries[2].subject) "#, ) .eval() .expect("log parse"); assert_eq!(result, "2|abc1234|second commit"); } #[test] fn magit_parse_branches_marks_current() { let (state, _c, _u) = editor_with_magit(); let lua = state.lua_host.lua(); let result: String = lua .load( r#" local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE local input = " feature\n* main\n release\n" local r = te.status.parse_branches(input) return string.format("current=%s|count=%d|first=%s", r.current, #r.all, r.all[1]) "#, ) .eval() .expect("branches parse"); // `git branch --list` outputs branches in encounter order // (alphabetical with the current-branch marker inline), so // `all[1]` is whichever branch sorts first --- "feature", not // the current one. The current is named separately via // `r.current`. assert_eq!(result, "current=main|count=3|first=feature"); } #[test] fn magit_parse_stashes_basic_input() { let (state, _c, _u) = editor_with_magit(); let lua = state.lua_host.lua(); let result: String = lua .load( r#" local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE local input = "stash@{0}: WIP on main: abc123 work\nstash@{1}: On main: def quick fix\n" local entries = te.status.parse_stashes(input) return string.format("%d|%s|%s", #entries, entries[1].ref, entries[2].subject) "#, ) .eval() .expect("stash parse"); assert_eq!(result, "2|stash@{0}|On main: def quick fix"); } // --------------------------------------------------------------------------- // Regression: build_spec produces stable section IDs // --------------------------------------------------------------------------- #[test] fn magit_build_spec_section_ids_are_stable_canonical_set() { let (state, _c, _u) = editor_with_magit(); let lua = state.lua_host.lua(); let ids: String = lua .load( r#" local te = require("pmacs-magit").__pmacs_magit_test_seam_DO_NOT_USE local spec = te.status.build_spec { status = { staged = {}, unstaged = {}, untracked = {} }, log = {}, branches = { current = nil, all = {} }, stashes = {}, } local out = {} for _, s in ipairs(spec) do out[#out + 1] = s.id end return table.concat(out, ",") "#, ) .eval() .expect("build_spec ids"); assert_eq!(ids, "working,staged,log,branches,stashes"); } // Isolated bootstrap storage roots (see the module docs): an // integration test is compiled without `cfg(test)`, so a raw // `EditorState::new()` would read the developer's real `init.lua` and // write into their real data root. #[path = "common/iso.rs"] mod iso;