diff --git a/terminal/menus/mq-main-menu.sh b/terminal/menus/mq-main-menu.sh index 26337a1..5fc477e 100755 --- a/terminal/menus/mq-main-menu.sh +++ b/terminal/menus/mq-main-menu.sh @@ -182,17 +182,6 @@ surface_compact_dual_figure_row() { "$C_RESET" } -# Formats git state for the compact terminal surface. -surface_git_state() { - local count - count="$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')" - if [[ -z "$count" || "$count" == "0" ]]; then - printf "Clean" - else - printf "Dirty (%s)" "$count" - fi -} - # Renders the command surface view for terminal output. render_command_surface() { local USER_NAME HOST_NAME TIME SURFACE_COLOR FIGURE_COLOR ALT_FIGURE_COLOR width git_state tip activity system_state diff --git a/terminal/menus/mq-release-menu.sh b/terminal/menus/mq-release-menu.sh index 23f5a81..95f5412 100755 --- a/terminal/menus/mq-release-menu.sh +++ b/terminal/menus/mq-release-menu.sh @@ -243,11 +243,19 @@ show_release_status() { row_bold "RELEASE STATUS" empty_row + local snapshot change_count state severity next_action + snapshot="$(mq_git_status_snapshot "$RELEASE_REPO")" + change_count="$(printf '%s' "$snapshot" | cut -d'|' -f4)" + state="$(printf '%s' "$snapshot" | cut -d'|' -f5)" + severity="$(printf '%s' "$snapshot" | cut -d'|' -f6)" + next_action="$(printf '%s' "$snapshot" | cut -d'|' -f7)" + row "Repo: $RELEASE_REPO" row "Current version: $(current_version)" row "Latest tag: $(latest_tag || true)" - row "Release script: $RELEASE_SCRIPT" - row "Changelog: $CHANGELOG_FILE" + row "Working tree: $state ($change_count changes)" + row "Severity: $severity" + row "Next action: $next_action" print_footer pause_enter @@ -697,19 +705,72 @@ auto_release() { pause_enter } -# Returns a one-line status string for the menu footer. +# Returns a compact status string for the menu footer. release_status_line() { - local files_status + local files_status snapshot change_count files_status="$(_release_files_status)" case "$files_status" in missing:*) - printf 'not initialized — missing: %s → run option 3' "${files_status#missing:}" + printf 'not initialized' ;; not_executable) - printf 'release.sh not executable → run option 3' + printf 'release.sh not executable' + ;; + *) + snapshot="$(mq_git_status_snapshot "$RELEASE_REPO")" + change_count="$(printf '%s' "$snapshot" | cut -d'|' -f4)" + if (( change_count > 0 )); then + printf 'blocked — dirty (%s)' "$change_count" + else + printf 'ready (v%s)' "$(current_version)" + fi + ;; + esac +} + +# Returns release status detail for the menu footer. +release_status_detail() { + local files_status missing_files snapshot change_count severity + files_status="$(_release_files_status)" + case "$files_status" in + missing:*) + missing_files="${files_status#missing:}" + missing_files="${missing_files//,/ , }" + missing_files="${missing_files// ,/,}" + printf 'Missing: %s' "$missing_files" + ;; + not_executable) + printf 'Script: %s' "$RELEASE_SCRIPT" + ;; + *) + snapshot="$(mq_git_status_snapshot "$RELEASE_REPO")" + change_count="$(printf '%s' "$snapshot" | cut -d'|' -f4)" + severity="$(printf '%s' "$snapshot" | cut -d'|' -f6)" + if (( change_count > 0 )); then + printf 'Severity: %s — review or stash changes before release' "$severity" + else + printf 'Latest tag: %s' "$(latest_tag || true)" + fi + ;; + esac +} + +# Returns the next recommended release menu action. +release_status_next() { + local files_status snapshot change_count + files_status="$(_release_files_status)" + case "$files_status" in + missing:*|not_executable) + printf 'Next: 3. Initialize files' ;; *) - printf 'ready (v%s)' "$(current_version)" + snapshot="$(mq_git_status_snapshot "$RELEASE_REPO")" + change_count="$(printf '%s' "$snapshot" | cut -d'|' -f4)" + if (( change_count > 0 )); then + printf 'Next: 1. Review status' + else + printf 'Next: 4. Dry run release' + fi ;; esac } @@ -757,7 +818,9 @@ print_release_menu() { surface_split_row "7. View changelog" "8. Show latest tags" "$width" "$panel_color" surface_split_row "9. Open changelog" "10. Open release script" "$width" "$panel_color" surface_row "" "$width" "$panel_color" - surface_row "Status: $(release_status_line)" "$width" "$panel_color" + surface_row "STATUS" "$width" "$panel_color" + surface_split_row "Status: $(release_status_line)" "$(release_status_next)" "$width" "$panel_color" + surface_row "$(release_status_detail)" "$width" "$panel_color" surface_bottom "$width" "$panel_color" printf '\n' } diff --git a/tests/git-status-contract-smoke.sh b/tests/git-status-contract-smoke.sh new file mode 100755 index 0000000..e2e9fd1 --- /dev/null +++ b/tests/git-status-contract-smoke.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_REPO="$(mktemp -d)" +trap 'rm -rf "$TMP_REPO"' EXIT + +fail() { + printf '[FAIL] %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf '[PASS] %s\n' "$1" +} + +git -C "$TMP_REPO" init -q +git -C "$TMP_REPO" config user.name "MQ Test" +git -C "$TMP_REPO" config user.email "mq-test@example.invalid" +printf 'base\n' > "$TMP_REPO/tracked.txt" +git -C "$TMP_REPO" add tracked.txt +git -C "$TMP_REPO" commit -qm "base" + +mkdir -p "$TMP_REPO/generated" +printf 'one\n' > "$TMP_REPO/generated/one.txt" +printf 'two\n' > "$TMP_REPO/generated/two.txt" +printf 'three\n' > "$TMP_REPO/generated/three.txt" +printf 'changed\n' >> "$TMP_REPO/tracked.txt" + +# shellcheck disable=SC1090 +source "$ROOT/ui/terminal-ui/mq-ui.sh" +snapshot="$(mq_git_status_snapshot "$TMP_REPO")" +[[ "$(printf '%s' "$snapshot" | cut -d'|' -f1)" == "0" ]] || fail "staged count" +[[ "$(printf '%s' "$snapshot" | cut -d'|' -f2)" == "1" ]] || fail "unstaged count" +[[ "$(printf '%s' "$snapshot" | cut -d'|' -f3)" == "1" ]] || fail "untracked directory count" +[[ "$(printf '%s' "$snapshot" | cut -d'|' -f4)" == "2" ]] || fail "canonical change count" +[[ "$(printf '%s' "$snapshot" | cut -d'|' -f5)" == "DIRTY" ]] || fail "dirty state" +[[ "$(printf '%s' "$snapshot" | cut -d'|' -f6)" == "LOW" ]] || fail "dirty severity" +[[ "$(surface_git_state "$TMP_REPO")" == "Dirty (2)" ]] || fail "surface state" +pass "shared Git snapshot uses porcelain entries" + +zsh_snapshot="$(zsh -c 'source "$1"; mq_git_status_snapshot "$2"' _ "$ROOT/ui/terminal-ui/mq-ui.sh" "$TMP_REPO")" +[[ "$(printf '%s' "$zsh_snapshot" | cut -d'|' -f4)" == "2" ]] || fail "zsh snapshot" +pass "shared Git snapshot works in zsh" + +MACOS_SCRIPTS_HOME="$ROOT" +# shellcheck disable=SC1090 +source "$ROOT/terminal/menus/mq-release-menu.sh" +RELEASE_REPO="$TMP_REPO" +printf '1.0.0\n' > "$TMP_REPO/VERSION" +printf '# Changelog\n' > "$TMP_REPO/CHANGELOG.md" +printf '#!/usr/bin/env bash\n' > "$TMP_REPO/release.sh" +chmod +x "$TMP_REPO/release.sh" +refresh_release_paths + +[[ "$(release_status_line)" == "blocked — dirty (5)" ]] || fail "release blocked count" +[[ "$(release_status_next)" == "Next: 1. Review status" ]] || fail "release next action" +pass "release footer blocks dirty repositories" + +git -C "$TMP_REPO" add -A +git -C "$TMP_REPO" commit -qm "clean" +[[ "$(release_status_line)" == "ready (v1.0.0)" ]] || fail "clean release status" +pass "release footer reports clean repositories ready" + +dashboard="$( (cd "$TMP_REPO" && MACOS_SCRIPTS_HOME="$ROOT" bash "$ROOT/ui/ascii/mqlaunch-dashboard-v7.1.sh") 2>&1)" +printf '%s\n' "$dashboard" | grep -q 'DIRTY 0 files' && fail "dashboard unexpectedly dirty" +pass "dashboard consumes shared Git snapshot" + +printf 'OK: shared Git/release status contract passed\n' diff --git a/tools/scripts/test-all.sh b/tools/scripts/test-all.sh index b153d55..fe8971b 100755 --- a/tools/scripts/test-all.sh +++ b/tools/scripts/test-all.sh @@ -10,6 +10,7 @@ echo "== Running mqlaunch legacy/bridge checks ==" echo echo "== Running mqlaunch headless checks ==" "$PROJECT_ROOT/tests/headless-smoke.sh" +"$PROJECT_ROOT/tests/git-status-contract-smoke.sh" echo echo "== Running HAL menu checks ==" diff --git a/ui/ascii/mqlaunch-dashboard-v7.1.sh b/ui/ascii/mqlaunch-dashboard-v7.1.sh index 862ae88..98fb616 100755 --- a/ui/ascii/mqlaunch-dashboard-v7.1.sh +++ b/ui/ascii/mqlaunch-dashboard-v7.1.sh @@ -33,6 +33,14 @@ ACCENT_YELLOW="${C_YELLOW}" ACCENT_RED="${C_RED}" ACCENT_DIM="${C_DIM}" +_MQ_DASHBOARD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +_MQ_DASHBOARD_ROOT="$(cd "$_MQ_DASHBOARD_DIR/../.." && pwd)" +_MQ_DASHBOARD_UI="$_MQ_DASHBOARD_ROOT/ui/terminal-ui/mq-ui.sh" +if [[ -f "$_MQ_DASHBOARD_UI" ]]; then + # shellcheck disable=SC1090 + source "$_MQ_DASHBOARD_UI" +fi + # Handles mq strip ansi. mq_strip_ansi() { printf '%s' "$1" | perl -pe 's/\e\[[0-9;]*m//g' @@ -394,7 +402,7 @@ mqlaunch_dashboard_v71() { local width compact local mode_color state_color severity severity_color local user host now shell_name os_name cwd repo branch dirty counts staged unstaged untracked ahead_behind - local total_changes next_action workspace_summary + local snapshot total_changes next_action workspace_summary local mem_widget batt_widget bar_max width="$(mq_term_width)" @@ -409,11 +417,14 @@ mqlaunch_dashboard_v71() { cwd="$(mq_cwd)" repo="$(mq_git_repo)" branch="$(mq_git_branch)" - dirty="$(mq_git_dirty_state)" - counts="$(mq_git_counts)" - staged="$(printf '%s' "$counts" | cut -d'|' -f1)" - unstaged="$(printf '%s' "$counts" | cut -d'|' -f2)" - untracked="$(printf '%s' "$counts" | cut -d'|' -f3)" + snapshot="$(mq_git_status_snapshot "$cwd")" + staged="$(printf '%s' "$snapshot" | cut -d'|' -f1)" + unstaged="$(printf '%s' "$snapshot" | cut -d'|' -f2)" + untracked="$(printf '%s' "$snapshot" | cut -d'|' -f3)" + total_changes="$(printf '%s' "$snapshot" | cut -d'|' -f4)" + dirty="$(printf '%s' "$snapshot" | cut -d'|' -f5)" + severity="$(printf '%s' "$snapshot" | cut -d'|' -f6)" + next_action="$(printf '%s' "$snapshot" | cut -d'|' -f7)" ahead_behind="$(mq_git_ahead_behind)" mem_widget="$(mq_memory_widget)" batt_widget="$(mq_battery_widget)" @@ -424,11 +435,11 @@ mqlaunch_dashboard_v71() { bar_max=$(( staged + unstaged + untracked )) (( bar_max < 5 )) && bar_max=5 - total_changes=$(( staged + unstaged + untracked )) - severity="$(mq_dirty_severity "$staged" "$unstaged" "$untracked")" severity_color="$(mq_dirty_severity_color "$severity")" - next_action="$(mq_git_next_action "$staged" "$unstaged" "$untracked" "$ahead_behind")" + if (( total_changes == 0 )); then + next_action="$(mq_git_next_action "$staged" "$unstaged" "$untracked" "$ahead_behind")" + fi mode_color="$(mq_mode_color "$mode")" state_color="$(mq_state_color "$dirty")" diff --git a/ui/terminal-ui/mq-ui.sh b/ui/terminal-ui/mq-ui.sh index ca71cb4..a722fda 100644 --- a/ui/terminal-ui/mq-ui.sh +++ b/ui/terminal-ui/mq-ui.sh @@ -125,13 +125,64 @@ surface_split_row() { "$C_RESET" } +# Returns the canonical Git status snapshot used by mqlaunch surfaces. +# Format: staged|unstaged|untracked|changes|state|severity|next_action +mq_git_status_snapshot() { + local repo="${1:-.}" + local porcelain staged unstaged untracked changes state severity next_action + + if ! git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf '0|0|0|0|NO_REPO|UNKNOWN|Open a Git repository' + return 0 + fi + + porcelain="$(git -C "$repo" status --porcelain=v1 --untracked-files=normal 2>/dev/null || true)" + staged="$(printf '%s\n' "$porcelain" | awk 'length($0) >= 2 && substr($0, 1, 1) != " " && substr($0, 1, 1) != "?" {count++} END {print count + 0}')" + unstaged="$(printf '%s\n' "$porcelain" | awk 'length($0) >= 2 && substr($0, 1, 2) != "??" && substr($0, 2, 1) != " " {count++} END {print count + 0}')" + untracked="$(printf '%s\n' "$porcelain" | awk 'substr($0, 1, 2) == "??" {count++} END {print count + 0}')" + changes="$(printf '%s\n' "$porcelain" | awk 'length($0) >= 2 {count++} END {print count + 0}')" + + if (( changes == 0 )); then + state="CLEAN" + severity="STABLE" + next_action="Nothing to commit" + else + state="DIRTY" + if (( changes <= 2 )); then + severity="LOW" + elif (( changes <= 6 )); then + severity="MEDIUM" + elif (( changes <= 12 )); then + severity="HIGH" + else + severity="CRITICAL" + fi + + if (( unstaged > 0 || untracked > 0 )); then + next_action="Review diff, then stage selected files" + elif (( staged > 0 )); then + next_action="Commit staged changes" + else + next_action="Review git status" + fi + fi + + printf '%s|%s|%s|%s|%s|%s|%s' \ + "$staged" "$unstaged" "$untracked" "$changes" "$state" "$severity" "$next_action" +} + # Handles surface git state. surface_git_state() { - local count - count="$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')" + local repo="${1:-.}" + local snapshot count state + snapshot="$(mq_git_status_snapshot "$repo")" + count="$(printf '%s' "$snapshot" | cut -d'|' -f4)" + state="$(printf '%s' "$snapshot" | cut -d'|' -f5)" - if [[ -z "$count" || "$count" == "0" ]]; then + if [[ "$state" == "CLEAN" ]]; then printf "Clean" + elif [[ "$state" == "NO_REPO" ]]; then + printf "No Git" else printf "Dirty (%s)" "$count" fi