From 0133f52f465f9c10e6c8f5f4b491d6b70fee60cb Mon Sep 17 00:00:00 2001 From: McAmner Date: Mon, 10 Aug 2026 02:47:55 +0200 Subject: [PATCH 1/2] fix(gitlaunch): recover after protected push --- mqlaunch/lib/git-menus.sh | 15 +++- terminal/launchers/gitlaunch.sh | 62 +++++++++++++--- tests/gitlaunch-pr-push-recovery-smoke.sh | 89 +++++++++++++++++++++++ tests/manifest.tsv | 1 + tests/mq-git-protected-push-smoke.sh | 19 +++++ tools/scripts/test-all.sh | 1 + 6 files changed, 171 insertions(+), 16 deletions(-) create mode 100755 tests/gitlaunch-pr-push-recovery-smoke.sh diff --git a/mqlaunch/lib/git-menus.sh b/mqlaunch/lib/git-menus.sh index a52aa42..c831a21 100755 --- a/mqlaunch/lib/git-menus.sh +++ b/mqlaunch/lib/git-menus.sh @@ -12,6 +12,13 @@ # bash, so it carries a bash shebang and is covered by shellcheck. # Opens git menu. +git_menu_exit_is_restartable() { + case "${1:-1}" in + 0|130|143) return 0 ;; + *) return 1 ;; + esac +} + open_git_menu() { local repo_arg="${1:-}" local git_script="$BASE_DIR/terminal/launchers/gitlaunch.sh" @@ -49,10 +56,10 @@ open_git_menu() { [[ -f "$back_marker" ]] && break - # A non-zero exit is a failure to start, not the mid-session crash the - # restart counter is for. Restarting five times would print the same - # "Repo path not found" five times before giving up. - if (( menu_status != 0 )); then + # Ordinary non-zero exits are startup or validation failures. Signal-safe + # exits from an active Git operation (130/143) are restartable so an + # interrupted post-push cleanup returns to Gitlaunch, not the main menu. + if ! git_menu_exit_is_restartable "$menu_status"; then break fi diff --git a/terminal/launchers/gitlaunch.sh b/terminal/launchers/gitlaunch.sh index 9b32d56..40c87d4 100755 --- a/terminal/launchers/gitlaunch.sh +++ b/terminal/launchers/gitlaunch.sh @@ -11,6 +11,8 @@ REQUESTED_REPO="${MQ_GIT_REPO:-${1:-}}" WORK_DIR="" _BANNER_SHOWN=0 BACK_MARKER="${MQ_GITLAUNCH_BACK_MARKER:-}" +PR_RESTORE_BASE_BRANCH="" +PR_RESTORE_REPO="" if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null)" -ge 8 ]]; then C_RESET=$'\e[0m' @@ -816,11 +818,41 @@ function branch_slug() { echo "${slug:0:48}" } +# Restores the checkout after a protected-branch push. Keeping this state in +# the gitlaunch process lets the EXIT trap recover even if a later command +# terminates the menu before normal cleanup runs. +function restore_pr_push_checkout() { + local restore_script + + if [[ -z "$PR_RESTORE_BASE_BRANCH" || -z "$PR_RESTORE_REPO" ]]; then + return 0 + fi + + restore_script="${GITLAUNCH_DIR}/../../tools/scripts/git-restore-to-base.sh" + if "$restore_script" "$PR_RESTORE_BASE_BRANCH" "$PR_RESTORE_REPO"; then + PR_RESTORE_BASE_BRANCH="" + PR_RESTORE_REPO="" + return 0 + fi + + echo "Gitlaunch could not restore the checkout after the PR push." >&2 + return 1 +} + +# Arms recovery before switching away from the protected base branch. +function arm_pr_push_restore() { + PR_RESTORE_BASE_BRANCH="$1" + PR_RESTORE_REPO="${2:-$PWD}" + trap 'restore_pr_push_checkout || true' EXIT + trap 'restore_pr_push_checkout || true; exit 130' INT + trap 'restore_pr_push_checkout || true; exit 143' TERM +} + # Creates pr branch for push through the configured workflow. function create_pr_branch_for_push() { local base_branch="$1" local commit_message="$2" - local slug pr_branch confirm output status + local slug pr_branch confirm output push_status slug="$(branch_slug "$commit_message")" pr_branch="mq/${slug}-$(date +%Y%m%d-%H%M%S)" @@ -837,16 +869,15 @@ function create_pr_branch_for_push() { return 1 fi - trap '"${GITLAUNCH_DIR}/../../tools/scripts/git-restore-to-base.sh" "$base_branch" "." || true; exit 130' INT - trap '"${GITLAUNCH_DIR}/../../tools/scripts/git-restore-to-base.sh" "$base_branch" "." || true; exit 143' TERM + arm_pr_push_restore "$base_branch" "$PWD" git switch -c "$pr_branch" 2>/dev/null || git checkout -b "$pr_branch" output=$(git push -u origin "$pr_branch" 2>&1) - status=$? + push_status=$? echo "$output" - local rc="$status" - if [[ "$status" -eq 0 ]]; then + local rc="$push_status" + if [[ "$push_status" -eq 0 ]]; then if command -v gh >/dev/null 2>&1; then echo "" printf "%bCreate the pull request now? [Y/n]: %b" "$C_LABEL" "$C_RESET" @@ -867,15 +898,18 @@ function create_pr_branch_for_push() { fi fi - "${GITLAUNCH_DIR}/../../tools/scripts/git-restore-to-base.sh" "$base_branch" "." || true - trap - INT TERM + if restore_pr_push_checkout; then + trap - EXIT INT TERM + else + rc=1 + fi return "$rc" } # Coordinates pr aware push behavior. function pr_aware_push() { local commit_message="${1:-update project files}" - local branch output status + local branch output push_status local -a push_args push_args=("${@:2}") @@ -896,15 +930,15 @@ function pr_aware_push() { else output=$(git push 2>&1) fi - status=$? + push_status=$? echo "$output" - if [[ "$status" -ne 0 ]] && echo "$output" | grep -E "GH013|Changes must be made through a pull request" >/dev/null; then + if [[ "$push_status" -ne 0 ]] && echo "$output" | grep -E "GH013|Changes must be made through a pull request" >/dev/null; then create_pr_branch_for_push "$branch" "$commit_message" return $? fi - return "$status" + return "$push_status" } # Runs push through guardrails before acting. @@ -1043,6 +1077,10 @@ function run_ai_commit() { # ------------------------ # WORKSPACE RESUME # ------------------------ +if [[ -n "${GITLAUNCH_SOURCE_ONLY:-}" ]]; then + return 0 2>/dev/null || exit 0 +fi + if [[ -z "$REQUESTED_REPO" ]] && load_state; then echo "🔁 Resume last workspace?" echo "Repo: $REPO" diff --git a/tests/gitlaunch-pr-push-recovery-smoke.sh b/tests/gitlaunch-pr-push-recovery-smoke.sh new file mode 100755 index 0000000..038cb8f --- /dev/null +++ b/tests/gitlaunch-pr-push-recovery-smoke.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Exercises the recovery functions from the gitlaunch implementation that +# mqlaunch option 3 actually starts. The older restore smoke covers the +# separate mq-git-menu implementation and did not catch this path. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GITLAUNCH="$ROOT/terminal/launchers/gitlaunch.sh" +GIT_MENUS="$ROOT/mqlaunch/lib/git-menus.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +git init -q --bare "$TMP/origin.git" +git clone -q "$TMP/origin.git" "$TMP/repo" +git -C "$TMP/repo" config user.email test@example.invalid +git -C "$TMP/repo" config user.name Test +git -C "$TMP/repo" checkout -q -B main +printf 'initial\n' > "$TMP/repo/file.txt" +git -C "$TMP/repo" add file.txt +git -C "$TMP/repo" commit -q -m initial +git -C "$TMP/repo" push -q -u origin main + +prepare_pr_branch() { + git -C "$TMP/repo" switch -q main + printf 'change-%s\n' "$1" > "$TMP/repo/file.txt" + git -C "$TMP/repo" commit -q -am "change $1" + git -C "$TMP/repo" switch -q -C "mq/test-$1" +} + +assert_restored() { + [[ "$(git -C "$TMP/repo" branch --show-current)" == main ]] + [[ "$(git -C "$TMP/repo" rev-parse main)" == "$(git -C "$TMP/repo" rev-parse origin/main)" ]] + [[ -z "$(git -C "$TMP/repo" status --porcelain)" ]] +} + +echo "[1/4] gitlaunch can be sourced without entering its menu loop" +GITLAUNCH_SOURCE_ONLY=1 GITLAUNCH="$GITLAUNCH" zsh -c ' + source "$GITLAUNCH" + whence -w restore_pr_push_checkout >/dev/null + whence -w arm_pr_push_restore >/dev/null +' + +echo "[2/4] normal recovery restores a clean base branch" +prepare_pr_branch normal +GITLAUNCH_SOURCE_ONLY=1 GITLAUNCH="$GITLAUNCH" TEST_REPO="$TMP/repo" zsh -c ' + source "$GITLAUNCH" + cd "$TEST_REPO" + arm_pr_push_restore main "$TEST_REPO" + restore_pr_push_checkout +' +assert_restored + +echo "[3/4] EXIT recovery restores after an unexpected process exit" +prepare_pr_branch exit +set +e +GITLAUNCH_SOURCE_ONLY=1 GITLAUNCH="$GITLAUNCH" TEST_REPO="$TMP/repo" zsh -c ' + source "$GITLAUNCH" + cd "$TEST_REPO" + arm_pr_push_restore main "$TEST_REPO" + exit 23 +' +exit_status=$? +set -e +[[ "$exit_status" -eq 23 ]] +assert_restored + +echo "[4/4] recovery failures are visible and signal-safe exits are restartable" +set +e +failure_output="$(GITLAUNCH_SOURCE_ONLY=1 GITLAUNCH="$GITLAUNCH" TEST_REPO="$TMP/repo" zsh -c ' + source "$GITLAUNCH" + cd "$TEST_REPO" + arm_pr_push_restore missing-base "$TEST_REPO" + restore_pr_push_checkout +' 2>&1)" +failure_status=$? +set -e +[[ "$failure_status" -ne 0 ]] +grep -q "Could not restore checkout" <<< "$failure_output" + +GIT_MENUS="$GIT_MENUS" bash -c ' + source "$GIT_MENUS" + git_menu_exit_is_restartable 0 + git_menu_exit_is_restartable 130 + git_menu_exit_is_restartable 143 + ! git_menu_exit_is_restartable 1 +' + +echo "gitlaunch PR-push recovery smoke OK" diff --git a/tests/manifest.tsv b/tests/manifest.tsv index 42d24f9..68d8054 100644 --- a/tests/manifest.tsv +++ b/tests/manifest.tsv @@ -38,6 +38,7 @@ git-menu-surface-smoke.sh active - git-restore-to-base-smoke.sh active - git-status-contract-smoke.sh active - gitlaunch-menu-surface-smoke.sh active - +gitlaunch-pr-push-recovery-smoke.sh active - gitmerge-safe-smoke.sh active - hal-args-no-eval-smoke.sh active - hal-command-surface-smoke.sh active - diff --git a/tests/mq-git-protected-push-smoke.sh b/tests/mq-git-protected-push-smoke.sh index 723e45a..7b03fab 100755 --- a/tests/mq-git-protected-push-smoke.sh +++ b/tests/mq-git-protected-push-smoke.sh @@ -45,4 +45,23 @@ grep -Fq "Staged:" "$LEGACY_MENU" grep -Fq "if [[ -t 0 && -t 1 ]]" "$LEGACY_MENU" grep -Fq "PR branch" "$DOCS" +# gitlaunch runs under zsh, where `status` is a read-only special parameter. +# Exercise the successful, non-protected push path: assigning a local named +# `status` terminates the submenu after a commit and returns to the main menu. +{ + sed -n '/^function pr_aware_push()/,/^}/p' "$LEGACY_MENU" + cat <<'ZSH_TEST' +function is_protected_branch() { return 1; } +function git() { + case "$1" in + branch) print -r -- "test/menu-loop" ;; + push) print -r -- "local push ok" ;; + *) return 1 ;; + esac +} + +pr_aware_push "menu loop regression" +ZSH_TEST +} | zsh >/dev/null + echo "mq git protected-push smoke OK" diff --git a/tools/scripts/test-all.sh b/tools/scripts/test-all.sh index eec4640..e6c9e24 100755 --- a/tools/scripts/test-all.sh +++ b/tools/scripts/test-all.sh @@ -26,6 +26,7 @@ echo "== Running mqlaunch headless checks ==" "$PROJECT_ROOT/tests/ui-spinner-smoke.sh" "$PROJECT_ROOT/tests/mq-stack-contract-smoke.sh" "$PROJECT_ROOT/tests/gitlaunch-menu-surface-smoke.sh" +"$PROJECT_ROOT/tests/gitlaunch-pr-push-recovery-smoke.sh" "$PROJECT_ROOT/tests/gitmerge-safe-smoke.sh" "$PROJECT_ROOT/tests/gitpr-merge-safe-smoke.sh" "$PROJECT_ROOT/tests/git-menu-surface-smoke.sh" From 586cd8fabfefb1b5a0ce6e0ae3e3587024fb4981 Mon Sep 17 00:00:00 2001 From: McAmner Date: Thu, 13 Aug 2026 04:24:04 +0200 Subject: [PATCH 2/2] update project files --- install.sh | 3 +++ mqlaunch/b2_tui/adapters/obsidian_writer.py | 7 +++++++ mqlaunch/b2_tui/config.py | 6 ++++++ system/tweaks/macos-tweaks.sh | 3 +++ tools/scripts/cleanup.sh | 3 +++ tools/scripts/mq-mcp-review.py | 5 +++++ 6 files changed, 27 insertions(+) diff --git a/install.sh b/install.sh index 72d3bab..adf80bc 100755 --- a/install.sh +++ b/install.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Install or uninstall macos-scripts entrypoint symlinks and its managed shell +# configuration block. Existing targets are replaced only after confirmation; +# --dry-run performs no filesystem changes. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/mqlaunch/b2_tui/adapters/obsidian_writer.py b/mqlaunch/b2_tui/adapters/obsidian_writer.py index 9e44fc0..4a57486 100644 --- a/mqlaunch/b2_tui/adapters/obsidian_writer.py +++ b/mqlaunch/b2_tui/adapters/obsidian_writer.py @@ -1,3 +1,9 @@ +"""Persist B2 prompt-run artifacts in the mqobsidian runs directory. + +This adapter writes presentation artifacts only; it does not promote durable +memory, rank recommendations, or invoke mqobsidian orchestration. +""" + from __future__ import annotations import re @@ -14,6 +20,7 @@ def _slug(text: str) -> str: def write_run(prompt: Prompt, task: str, composed: str) -> Path: + """Write one timestamped B2 run artifact and return its path.""" RUNS_DIR.mkdir(parents=True, exist_ok=True) ts = datetime.now().strftime("%Y-%m-%d-%H%M%S") filename = f"{ts}-b2-{_slug(task)}.md" diff --git a/mqlaunch/b2_tui/config.py b/mqlaunch/b2_tui/config.py index 4e16fa7..c10fcf8 100644 --- a/mqlaunch/b2_tui/config.py +++ b/mqlaunch/b2_tui/config.py @@ -1,3 +1,9 @@ +"""Define B2 TUI paths into the default mqobsidian checkout and local history. + +Paths currently assume ``~/mqobsidian`` and are not discovered from +``MQ_OBSIDIAN_DIR``; callers should treat missing paths as unavailable input. +""" + from __future__ import annotations from pathlib import Path diff --git a/system/tweaks/macos-tweaks.sh b/system/tweaks/macos-tweaks.sh index e2a6ff3..5947e9d 100755 --- a/system/tweaks/macos-tweaks.sh +++ b/system/tweaks/macos-tweaks.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Apply or revert curated macOS defaults changes. Mutating presets create a +# timestamped backup first; --dry-run prints commands without executing them. +# Command strings are internal constants and are executed through eval. set -euo pipefail SCRIPT_NAME="$(basename "$0")" diff --git a/tools/scripts/cleanup.sh b/tools/scripts/cleanup.sh index e7e5ace..936c2c2 100755 --- a/tools/scripts/cleanup.sh +++ b/tools/scripts/cleanup.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Interactively remove Trash contents, old Downloads files, Xcode DerivedData, +# and package-manager caches. Each destructive category requires confirmation; +# deletion failures are intentionally suppressed and may leave partial cleanup. set -euo pipefail CYAN='\033[0;36m' diff --git a/tools/scripts/mq-mcp-review.py b/tools/scripts/mq-mcp-review.py index f629ce4..795fd69 100755 --- a/tools/scripts/mq-mcp-review.py +++ b/tools/scripts/mq-mcp-review.py @@ -71,6 +71,11 @@ def is_allowed_file(path: Path) -> bool: def iter_target_files(targets: list[str], max_files: int) -> list[Path]: + """Return at most ``max_files`` eligible targets in deterministic order. + + Directories listed in ``SKIP_DIRS`` and secret-like filenames are excluded, + but this is filename-based filtering rather than a complete secret scanner. + """ found: list[Path] = [] for raw in targets: