From c3cb33040f7a42bc1f3ddec50b9bc8d4a673a113 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 16 Mar 2026 13:45:29 -0600 Subject: [PATCH 01/17] uman: Use get_buildman() for all buildman invocations Some places use a bare 'buildman' command which fails when buildman is not on the PATH but is available via $UBOOT_TOOLS. Use get_buildman() consistently so the UBOOT_TOOLS path is always used when set. Co-developed-by: Claude Opus 4.6 --- uman_pkg/build.py | 2 +- uman_pkg/cmdpy.py | 48 ++++++++++++++++++++++++++--------------------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/uman_pkg/build.py b/uman_pkg/build.py index 26e221f..27a9db6 100644 --- a/uman_pkg/build.py +++ b/uman_pkg/build.py @@ -171,7 +171,7 @@ def get_cmd(args, board, build_dir): Returns: list: Full buildman command including 'buildman' as first element """ - return ['buildman'] + get_buildman_args(args, board, build_dir) + return [get_buildman()] + get_buildman_args(args, board, build_dir) def base_bm_args(board, build_dir, lto=True): diff --git a/uman_pkg/cmdpy.py b/uman_pkg/cmdpy.py index 7395166..51a3a93 100644 --- a/uman_pkg/cmdpy.py +++ b/uman_pkg/cmdpy.py @@ -199,12 +199,20 @@ def list_boards_by_pattern(pattern): try: if uboot_dir: os.chdir(uboot_dir) - result = command.run_pipe([['buildman', '-nv', pattern]], capture=True, - capture_stderr=True, raise_on_error=False) + result = command.run_pipe( + [[build_mod.get_buildman(), '-nv', pattern]], capture=True, + capture_stderr=True, raise_on_error=False) finally: os.chdir(orig_dir) if result.return_code != 0: + stderr = result.stderr.strip() if result.stderr else '' + stdout = result.stdout.strip() if result.stdout else '' + msg = stderr or stdout + if msg: + last = msg.splitlines()[-1] + if 'No matching' not in last: + tout.warning(f'buildman: {last}') return [] boards = [] @@ -1157,8 +1165,8 @@ def do_pollute(args): base_dir = settings.get('build_dir', '/tmp/b') build_dir = f'{base_dir}/{args.board}-pollute' tout.notice(f'Building to {build_dir}...') - cmd = ['buildman'] + build_mod.base_bm_args(args.board, build_dir, - args.lto) + cmd = [build_mod.get_buildman()] + build_mod.base_bm_args( + args.board, build_dir, args.lto) result = exec_cmd(cmd, args.dry_run, capture=False) if result and result.return_code != 0: tout.error('Build failed') @@ -1263,23 +1271,21 @@ def do_pytest(args): # pylint: disable=too-many-return-statements,too-many-bran int: Exit code """ if args.list_boards: - qemu_boards = list_qemu_boards() - sandbox_boards = list_boards_by_pattern('sandbox') - m68k_boards = list_boards_by_pattern('M5208') - if qemu_boards: - tout.notice('Available QEMU boards:') - for board in qemu_boards: - print(f' {board}') - if m68k_boards: - tout.notice('Available m68k boards:') - for board in m68k_boards: - print(f' {board}') - if sandbox_boards: - tout.notice('Available sandbox boards:') - for board in sandbox_boards: - print(f' {board}') - if not qemu_boards and not sandbox_boards and not m68k_boards: - tout.warning('No boards found (is buildman configured?)') + found = False + for label, pattern in [('QEMU', 'qemu'), ('MicroBlaze', 'mbv'), + ('m68k', 'M5208'), + ('sandbox', 'sandbox')]: + boards = list_boards_by_pattern(pattern) + if boards: + tout.notice(f'Available {label} boards:') + for board in boards: + print(f' {board}') + found = True + elif not found: + # First pattern failed — database is probably empty + tout.warning( + 'No boards found (check ~/.buildman and $UBOOT_TOOLS)') + break return 0 # Handle -C option: run just the C test part From e8f2869f41789c5358552ecb88ba1a8f3b687119 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 16 Mar 2026 14:17:37 -0600 Subject: [PATCH 02/17] uman: Skip QEMU check when binary has shell variables Some test hook configs (e.g. MicroBlaze) use shell variables in the qemu_binary setting like 'qemu-system-riscv${VERSION}'. These cannot be resolved without evaluating the full shell script. Skip the QEMU binary check when the name contains unexpanded shell variables rather than failing with a confusing error. Co-developed-by: Claude Opus 4.6 --- uman_pkg/cmdpy.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/uman_pkg/cmdpy.py b/uman_pkg/cmdpy.py index 51a3a93..6b53560 100644 --- a/uman_pkg/cmdpy.py +++ b/uman_pkg/cmdpy.py @@ -409,6 +409,10 @@ def check_qemu_binary(board, board_id): if not binary: return None, True + # Skip check if binary contains unexpanded shell variables + if '$' in binary: + return None, True + return binary, shutil.which(binary) is not None From fb8120656f9a341fe7ac6386096627b03bc829c4 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 16 Mar 2026 14:24:37 -0600 Subject: [PATCH 03/17] uman: Set OPENSBI for MicroBlaze boards MicroBlaze boards use a RISC-V QEMU emulator and need OPENSBI firmware, but the board name does not contain 'riscv' so the OpenSBI environment setup is skipped. Match 'mbv' in addition to 'riscv' to set OPENSBI, and use the 32-bit firmware for mbv32 boards. Pass the pytest environment to the build step so OPENSBI is available during compilation. Co-developed-by: Claude Opus 4.6 --- uman_pkg/build.py | 9 +++++++-- uman_pkg/cmdpy.py | 11 ++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/uman_pkg/build.py b/uman_pkg/build.py index 27a9db6..56dcfc9 100644 --- a/uman_pkg/build.py +++ b/uman_pkg/build.py @@ -220,7 +220,7 @@ def get_buildman_args(args, board, build_dir): def build_board(board, dry_run=False, lto=False, adjust_cfg=None, force_reconfig=False, fresh=False, jobs=None, trace=False, - trace_early=True, output_dir=None): + trace_early=True, output_dir=None, extra_env=None): """Build U-Boot for a board Args: @@ -259,11 +259,16 @@ def build_board(board, dry_run=False, lto=False, adjust_cfg=None, bm_args.extend(['-a', cfg]) env = None + if extra_env or trace: + env = os.environ.copy() + if extra_env: + env.update(extra_env) if trace: bm_args.extend(['-a', 'TRACE']) if trace_early: bm_args.extend(['-a', 'TRACE_EARLY']) - env = os.environ.copy() + if not env: + env = os.environ.copy() env['FTRACE'] = '1' result = buildman(*bm_args, dry_run=dry_run, env=env, capture=False) diff --git a/uman_pkg/cmdpy.py b/uman_pkg/cmdpy.py index 6b53560..2b4c12e 100644 --- a/uman_pkg/cmdpy.py +++ b/uman_pkg/cmdpy.py @@ -79,7 +79,7 @@ def setup_riscv_env(board, env): env (dict): Environment variables dict to update """ # Select 32-bit or 64-bit OpenSBI based on board name - if 'riscv32' in board: + if 'riscv32' in board or 'mbv32' in board: opensbi = settings.get('opensbi_rv32', fallback=None) # Fallback: derive rv32 path from rv64 path if not opensbi: @@ -153,7 +153,7 @@ def pytest_env(board): """ env = {} - if 'riscv' in board: + if 'riscv' in board or 'mbv' in board: setup_riscv_env(board, env) if 'sbsa' in board: @@ -1352,21 +1352,22 @@ def do_pytest(args): # pylint: disable=too-many-return-statements,too-many-bran if cfg not in adjust_cfg: adjust_cfg.append(cfg) + pytest_vars = pytest_env(args.board) if not build_mod.build_board( args.board, args.dry_run, args.lto, adjust_cfg=adjust_cfg, force_reconfig=args.force_reconfig, fresh=args.fresh, jobs=args.jobs, trace=args.trace, trace_early=not args.no_trace_early, - output_dir=args.output_dir): + output_dir=args.output_dir, extra_env=pytest_vars): return 1 args.build = False # Don't build again in pytest + else: + pytest_vars = pytest_env(args.board) # Show -G command hint when using -g (not in dry-run mode) if args.gdb_phase and not args.gdb and not args.dry_run: tout.notice(f'In another terminal: um py -G -B {args.board}') - - pytest_vars = pytest_env(args.board) cmd = build_pytest_cmd(args) env = os.environ.copy() From 16dd10a825feed92ce90e5a33c5b65f133c3ba1f Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Mon, 16 Mar 2026 14:35:28 -0600 Subject: [PATCH 04/17] uman: Add qemu-build setup to build QEMU from source The Ubuntu QEMU packages do not include all machine types needed for U-Boot testing (e.g. amd-microblaze-v-generic). Add a 'qemu-build' setup component that clones the QEMU git repo to ~/dev/qemu, installs build dependencies, configures with the targets needed for U-Boot testing, and builds. The binaries are in ~/dev/qemu/build. Co-developed-by: Claude Opus 4.6 --- uman_pkg/cmdpy.py | 55 +++++++++++++++++++ uman_pkg/settings.py | 3 + uman_pkg/setup.py | 127 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+) diff --git a/uman_pkg/cmdpy.py b/uman_pkg/cmdpy.py index 2b4c12e..eaca37a 100644 --- a/uman_pkg/cmdpy.py +++ b/uman_pkg/cmdpy.py @@ -178,6 +178,12 @@ def pytest_env(board): hooks = hooks_bin path_parts.append(hooks) + # Add custom QEMU build if present + qemu_build = settings.get('qemu_build_dir', + fallback='~/dev/qemu/build') + if qemu_build and os.path.isdir(qemu_build): + path_parts.append(qemu_build) + if path_parts: current_path = os.environ.get('PATH', '') env['PATH'] = ':'.join(path_parts) + ':' + current_path @@ -395,6 +401,54 @@ def get_qemu_binary(board, board_id): return None +def show_pytest_hint(args): + """Show a hint about why pytest may have failed + + Checks the test log for common failure patterns and prints a + helpful message. + + Args: + args (argparse.Namespace): Arguments from cmdline + """ + if args.output_dir: + build_dir = args.output_dir + else: + base_dir = settings.get('build_dir', '/tmp/b') + build_dir = f'{base_dir}/{args.board}' + log_path = os.path.join(build_dir, 'test-log.html') + if not os.path.exists(log_path): + return + + try: + with open(log_path) as fh: + text = fh.read() + except OSError: + return + + # Check for common QEMU failures + if 'Lab failure' in text or 'Marking connection bad' in text: + # Look for QEMU error in the log + import html as html_mod + plain = re.sub(r'<[^>]+>', '\n', text) + plain = html_mod.unescape(plain) + for line in plain.splitlines(): + line = line.strip() + if ('qemu' in line.lower() and + ('not found' in line or 'No such file' in line or + 'No machine' in line or 'unsupported' in line or + 'error' in line.lower())): + tout.notice(f'Hint: {line}') + if 'unsupported' in line or 'No machine' in line: + tout.notice( + 'Try: uman setup qemu-build') + return + if 'Could not open' in line or 'Cannot open' in line: + tout.notice(f'Hint: {line}') + return + tout.notice('Hint: QEMU may have failed to start; check ' + f'{log_path}') + + def check_qemu_binary(board, board_id): """Check if the required QEMU binary is available @@ -1392,6 +1446,7 @@ def do_pytest(args): # pylint: disable=too-many-return-statements,too-many-bran print(result.stderr, file=sys.stderr) if not args.quiet: tout.error('pytest failed') + show_pytest_hint(args) else: if not args.quiet: tout.notice('pytest passed') diff --git a/uman_pkg/settings.py b/uman_pkg/settings.py index 1a29f65..9723938 100644 --- a/uman_pkg/settings.py +++ b/uman_pkg/settings.py @@ -27,6 +27,9 @@ opensbi = ~/dev/blobs/opensbi/fw_dynamic.bin opensbi_rv32 = ~/dev/blobs/opensbi/fw_dynamic_rv32.bin +# QEMU build directory (built by 'uman setup qemu-build') +qemu_build_dir = ~/dev/qemu/build + # TF-A firmware directory for ARM SBSA testing (built by 'uman setup') tfa_dir = ~/dev/blobs/tfa diff --git a/uman_pkg/setup.py b/uman_pkg/setup.py index f396025..a2d027a 100644 --- a/uman_pkg/setup.py +++ b/uman_pkg/setup.py @@ -29,11 +29,19 @@ 'efi': 'QEMU EFI firmware for ARM, ARM64, RISC-V and x86', 'gcc': 'GCC cross-compiler and build dependencies', 'qemu': 'QEMU emulators for all architectures', + 'qemu-build': 'Build QEMU from source (for MicroBlaze etc.)', 'opensbi': 'OpenSBI firmware for RISC-V', 'tfa': 'ARM Trusted Firmware for QEMU SBSA', 'xtensa': 'Xtensa dc233c toolchain', } +QEMU_REPO = 'https://gitlab.com/qemu-project/qemu.git' + +# Targets needed for U-Boot testing +QEMU_TARGETS = ('arm-softmmu,aarch64-softmmu,i386-softmmu,x86_64-softmmu,' + 'riscv32-softmmu,riscv64-softmmu,ppc-softmmu,m68k-softmmu,' + 'xtensa-softmmu,microblaze-softmmu') + UM_FUNC = 'um() { b="$b" USRC="$USRC" command um "$@"; }' @@ -255,6 +263,124 @@ def setup_qemu(args): return 0 +# Build dependencies for QEMU from source +QEMU_BUILD_DEPS = [ + 'git', 'build-essential', 'ninja-build', 'pkg-config', + 'libglib2.0-dev', 'libpixman-1-dev', 'libslirp-dev', +] + + +def run_logged(cmd, log, desc, cwd=None): + """Run a command, appending output to a log file + + Args: + cmd (list): Command and arguments + log (file): Open log file to append to + desc (str): Description for error messages + cwd (str or None): Working directory + + Returns: + bool: True on success + """ + log.write(f'## {desc}\n$ {" ".join(cmd)}\n') + log.flush() + result = command.run_pipe( + [cmd], capture=True, capture_stderr=True, + raise_on_error=False, cwd=cwd) + if result.stdout: + log.write(result.stdout) + if result.stderr: + log.write(result.stderr) + if result.return_code: + tout.error(f'{desc} failed (see {log.name})') + return False + return True + + +def setup_qemu_build(args): + """Clone and build QEMU from source + + Clones the QEMU git repo to ~/dev/qemu, builds it with the targets + needed for U-Boot testing. Output is logged to ~/dev/qemu/build.log + + Args: + args (argparse.Namespace): Command line arguments + + Returns: + int: Exit code (0 for success, non-zero for failure) + """ + qemu_dir = os.path.expanduser('~/dev/qemu') + build_dir = os.path.join(qemu_dir, 'build') + log_path = os.path.join(qemu_dir, 'build.log') + + if args.dry_run: + tout.notice(f'Would clone QEMU to {qemu_dir} and build') + return 0 + + # Install build dependencies + missing = [] + for pkg in QEMU_BUILD_DEPS: + try: + command.output('dpkg', '-s', pkg) + except command.CommandExc: + missing.append(pkg) + if missing: + tout.notice(f'Installing build deps: {" ".join(missing)}') + result = command.run_pipe( + [['sudo', 'apt-get', 'install', '-y'] + missing], + capture=False, raise_on_error=False) + if result.return_code: + tout.error('Failed to install build dependencies') + return 1 + + # Clone or update (log to parent dir until clone creates qemu_dir) + parent = os.path.dirname(qemu_dir) + os.makedirs(parent, exist_ok=True) + tmp_log = os.path.join(parent, 'qemu-build.log') + need_clone = not os.path.isdir(os.path.join(qemu_dir, '.git')) + + with open(tmp_log, 'w', encoding='utf-8') as log: + if not need_clone: + tout.progress('Updating QEMU source') + if not run_logged( + ['git', '-C', qemu_dir, 'pull', '--ff-only'], + log, 'git pull'): + tout.warning('git pull failed; building existing checkout') + else: + tout.progress('Cloning QEMU') + if not run_logged( + ['git', 'clone', '--depth=1', QEMU_REPO, qemu_dir], + log, 'git clone'): + return 1 + + # Move log into qemu dir now that it exists + shutil.move(tmp_log, log_path) + + with open(log_path, 'a', encoding='utf-8') as log: + + # Configure + os.makedirs(build_dir, exist_ok=True) + tout.progress('Configuring QEMU') + if not run_logged( + [os.path.join(qemu_dir, 'configure'), + f'--target-list={QEMU_TARGETS}', + '--disable-docs', '--disable-user'], + log, 'configure', cwd=build_dir): + return 1 + + # Build + jobs = os.cpu_count() or 4 + tout.progress(f'Building QEMU ({jobs} jobs)') + if not run_logged( + ['make', f'-j{jobs}'], + log, 'make', cwd=build_dir): + return 1 + + tout.clear_progress() + tout.notice(f'QEMU built in {build_dir} (log: {log_path})') + return 0 + + # EFI firmware packages for QEMU EFI_PACKAGES = [ 'ovmf', @@ -627,6 +753,7 @@ def do_setup(args): 'efi': lambda: setup_efi(args), 'gcc': lambda: setup_gcc(args), 'qemu': lambda: setup_qemu(args), + 'qemu-build': lambda: setup_qemu_build(args), 'opensbi': lambda: setup_opensbi(blobs_dir, args), 'tfa': lambda: setup_tfa(blobs_dir, args), 'xtensa': lambda: setup_xtensa(blobs_dir, args), From 7e6db975dc9530d0fcfd059d84259115a2898c2e Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Wed, 18 Mar 2026 07:54:59 -0600 Subject: [PATCH 05/17] uman: Add progress messages to container creation Container creation involves several slow steps (downloading the image, installing packages, setting up Claude Code) with no feedback. Add tout.progress() calls for each major step so the user can see what is happening. These show as green transient status lines on TTYs and are cleared before launching the shell. Co-developed-by: Claude Opus 4.6 (1M context) --- uman_pkg/cc.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/uman_pkg/cc.py b/uman_pkg/cc.py index d6f414f..5de3ca4 100644 --- a/uman_pkg/cc.py +++ b/uman_pkg/cc.py @@ -1031,8 +1031,10 @@ def run(args): # pylint: disable=too-many-locals,too-many-branches,too-many-sta sock_path = os.path.join(project_src, EDITOR_SOCK) try: if not existed: + tout.progress('Creating container') create_container(name, base, dry_run) + tout.progress('Setting up mounts') add_all_mounts(name, project_src, args.mount, args.output, args.no_output, dry_run) @@ -1090,6 +1092,7 @@ def run(args): # pylint: disable=too-many-locals,too-many-branches,too-many-sta tout.notice( 'Running in privileged mode (device-mapper enabled)') + tout.progress('Starting container') ensure_running(name, existed, dry_run) # In privileged mode, uid namespacing is disabled, so the @@ -1109,11 +1112,17 @@ def run(args): # pylint: disable=too-many-locals,too-many-branches,too-many-sta dry_run=dry_run) # Wait for user and set up (idempotent operations) + tout.progress('Waiting for container to be ready') wait_for_user(name, dry_run) + tout.progress('Configuring container') setup_container(name, dry_run) + tout.progress('Installing packages') install_tools(name, packages, dry_run) + tout.progress('Installing Claude Code') install_claude(name, dry_run) + tout.progress('Setting up uman') setup_uman(name, uboot_tools, dry_run) + tout.clear_progress() # Check X11 access for clipboard (image paste) if not dry_run and os.path.isdir('/tmp/.X11-unix'): From be1e7fd06aa67b30f837d9617f786604e6753658 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Thu, 19 Mar 2026 09:50:24 -0600 Subject: [PATCH 06/17] uman: Auto-reconnect GDB when remote connection closes When debugging U-Boot with 'um py -G', a restart causes GDB to lose the remote connection. The user must then manually type the reconnect and continue commands each time. Add a gdb_monitor() function that runs GDB in a pseudo-terminal to monitor its output. When 'Remote connection closed' appears, it automatically sends 'target remote' and 'c' to resume the session. This replaces the previous os.execvp() approach, and also handles terminal resizing (SIGWINCH) properly. Co-developed-by: Claude Opus 4.6 (1M context) --- uman_pkg/cmdpy.py | 104 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/uman_pkg/cmdpy.py b/uman_pkg/cmdpy.py index eaca37a..98df164 100644 --- a/uman_pkg/cmdpy.py +++ b/uman_pkg/cmdpy.py @@ -1005,6 +1005,107 @@ def run_c_test(args): return result.return_code +def gdb_monitor(gdb_cmd, channel): + """Run GDB and auto-reconnect when the remote connection closes + + Runs GDB in a pseudo-terminal to monitor its output. When + 'Remote connection closed' appears, automatically sends reconnect + and continue commands so the debug session resumes after U-Boot + restarts. + + Args: + gdb_cmd (list of str): GDB command and arguments + channel (str): Remote channel (e.g. 'localhost:1234') + + Returns: + int: GDB exit code + """ + import fcntl # pylint: disable=import-outside-toplevel + import pty # pylint: disable=import-outside-toplevel + import select # pylint: disable=import-outside-toplevel + import signal # pylint: disable=import-outside-toplevel + import termios # pylint: disable=import-outside-toplevel + import tty # pylint: disable=import-outside-toplevel + + master_fd, slave_fd = pty.openpty() + + # Copy host terminal size to pty + try: + winsz = fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ, b'\0' * 8) + fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsz) + except OSError: + pass + + proc = subprocess.Popen( + gdb_cmd, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, + preexec_fn=os.setsid, close_fds=True) + os.close(slave_fd) + + # Forward terminal resizes to GDB + orig_winch = signal.getsignal(signal.SIGWINCH) + + def on_winch(signo, frame): # pylint: disable=unused-argument + try: + winsz = fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ, b'\0' * 8) + fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsz) + os.kill(proc.pid, signal.SIGWINCH) + except OSError: + pass + + signal.signal(signal.SIGWINCH, on_winch) + + old_attr = termios.tcgetattr(sys.stdin) + try: + tty.setraw(sys.stdin) + + buf = b'' + trigger = b'Remote connection closed' + reconnect = f'target remote {channel}\nc\n'.encode() + + while True: + try: + rlist = select.select( + [sys.stdin, master_fd], [], [], 0.5)[0] + except (InterruptedError, select.error): + continue + + if not rlist and proc.poll() is not None: + break + + if sys.stdin in rlist: + try: + data = os.read(sys.stdin.fileno(), 1024) + except OSError: + break + if not data: + break + os.write(master_fd, data) + + if master_fd in rlist: + try: + data = os.read(master_fd, 4096) + except OSError: + break + if not data: + break + os.write(sys.stdout.fileno(), data) + + buf += data + if trigger in buf: + buf = b'' + time.sleep(0.5) + os.write(master_fd, reconnect) + elif len(buf) > 1024: + buf = buf[-512:] + + proc.wait() + return proc.returncode + finally: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_attr) + signal.signal(signal.SIGWINCH, orig_winch) + os.close(master_fd) + + def run_with_gdb(args): """Launch gdb to connect to an existing gdbserver @@ -1045,8 +1146,7 @@ def run_with_gdb(args): print(' '.join(gdb_cmd)) return 0 - # Replace this process with gdb so Ctrl-C is handled by gdb - os.execvp(gdb_cmd[0], gdb_cmd) + return gdb_monitor(gdb_cmd, channel) def collect_tests(args): From 4319fd585703be22c58e39848d2e88cce368c1f8 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Sun, 22 Mar 2026 06:13:29 -0600 Subject: [PATCH 07/17] uman: Add -g option to run sandbox tests under gdb-multiarch The test subcommand has no way to debug tests under gdb. The py subcommand supports -g for gdbserver, but for direct sandbox tests a simpler approach works: run the sandbox executable directly under gdb-multiarch with the test arguments passed via -ex 'run ...'. Add a -g/--gdb flag to the test subcommand that launches gdb-multiarch with quiet mode, auto-load safe path, debuginfod disabled, SIGUSR2 handling, and a 'run' command pre-loaded with the sandbox arguments. This supports dry-run mode as well. Co-developed-by: Claude Opus 4.6 (1M context) --- README.rst | 1 + uman_pkg/cmdline.py | 3 +++ uman_pkg/cmdtest.py | 36 ++++++++++++++++++++++++++++++++++++ uman_pkg/ftest.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/README.rst b/README.rst index 94c7399..c890fb8 100644 --- a/README.rst +++ b/README.rst @@ -864,6 +864,7 @@ without going through pytest. This is faster for quick iteration on C code. - ``-f, --force-reconfig``: Force reconfiguration (use with -b) - ``-F, --fresh``: Delete build dir before building (use with -b) - ``--flattree-too``: Run both live-tree and flat-tree tests (default: live-tree only) +- ``-g, --gdb``: Run sandbox under gdb-multiarch - ``-j, --jobs JOBS``: Number of parallel jobs (use with -b) - ``-l, --list``: List available tests - ``-L, --lto``: Enable LTO when building (use with -b) diff --git a/uman_pkg/cmdline.py b/uman_pkg/cmdline.py index 01440f9..ca3043a 100644 --- a/uman_pkg/cmdline.py +++ b/uman_pkg/cmdline.py @@ -394,6 +394,9 @@ def add_test_subparser(subparsers): test.add_argument( '-b', '--build', action='store_true', help='Build before running tests') + test.add_argument( + '-g', '--gdb', action='store_true', + help='Run sandbox under gdb-multiarch') test.add_argument( '-B', '--board', metavar='BOARD', default='sandbox', help='Board to build/test (default: sandbox)') diff --git a/uman_pkg/cmdtest.py b/uman_pkg/cmdtest.py index 7adf9d5..c5c413d 100644 --- a/uman_pkg/cmdtest.py +++ b/uman_pkg/cmdtest.py @@ -886,6 +886,39 @@ def check_signal(return_code): return None +def run_gdb(cmd, args): + """Run sandbox under gdb-multiarch + + Args: + cmd (list): Sandbox command and arguments + args (argparse.Namespace): Arguments from cmdline + + Returns: + int: Exit code from gdb + """ + import subprocess # pylint: disable=import-outside-toplevel + + run_args = shlex.join(cmd[1:]) + gdb_cmd = [ + 'gdb-multiarch', + '-q', + cmd[0], + '-iex', 'set auto-load safe-path /', + '-iex', 'set debuginfod enabled off', + '-iex', 'set sysroot', + '-iex', 'handle SIGUSR2 nostop noprint pass', + '-ex', f'run {run_args}', + ] + + if args.dry_run: + tout.notice(shlex.join(gdb_cmd)) + return 0 + + tout.info(f"Running: {shlex.join(gdb_cmd)}") + result = subprocess.run(gdb_cmd, check=False) + return result.returncode + + def run_tests(sandbox, specs, args, col): """Run sandbox tests @@ -907,6 +940,9 @@ def run_tests(sandbox, specs, args, col): malloc_dump=args.malloc_dump, leak_check=args.leak_check) + if args.gdb: + return run_gdb(cmd, args) + if args.dry_run: tout.notice(shlex.join(cmd)) return 0 diff --git a/uman_pkg/ftest.py b/uman_pkg/ftest.py index a3f91fb..fecef7d 100644 --- a/uman_pkg/ftest.py +++ b/uman_pkg/ftest.py @@ -5997,6 +5997,50 @@ def test_ensure_dm_init_files_pytest_fails(self): result = cmdtest.ensure_dm_init_files() self.assertFalse(result) + def test_run_tests_gdb(self): + """Test run_tests with -g launches gdb-multiarch""" + cap = [] + + def mock_run(cmd, **kwargs): + cap.append((cmd, kwargs)) + return mock.MagicMock(returncode=0) + + args = cmdline.parse_args(['test', '-g', 'dm']) + col = terminal.Color() + with mock.patch.object(cmdtest, 'has_emit_result', + return_value=True): + with mock.patch.object(cmdtest, 'has_no_flat', + return_value=True): + with mock.patch.object(cmdtest, 'ensure_dm_init_files', + return_value=True): + with mock.patch('subprocess.run', mock_run): + with terminal.capture(): + result = cmdtest.run_tests( + '/sb', [('dm', None)], args, col) + self.assertEqual(0, result) + cmd = cap[0][0] + self.assertEqual('gdb-multiarch', cmd[0]) + self.assertIn('/sb', cmd) + self.assertIn('run -T -F -c \'ut -E dm\'', cmd[-1]) + + def test_run_tests_gdb_dry_run(self): + """Test run_tests with -g and -n shows gdb command""" + args = cmdline.parse_args(['-n', 'test', '-g', 'dm']) + col = terminal.Color() + with mock.patch.object(cmdtest, 'has_emit_result', + return_value=True): + with mock.patch.object(cmdtest, 'has_no_flat', + return_value=True): + with mock.patch.object(cmdtest, 'ensure_dm_init_files', + return_value=True): + with terminal.capture() as (out, err): + result = cmdtest.run_tests( + '/sb', [('dm', None)], args, col) + self.assertEqual(0, result) + output = out.getvalue() + self.assertIn('gdb-multiarch', output) + self.assertIn('/sb', output) + class TestPytestCTest(TestBase): """Tests for the pytest -C (C test) functionality""" From f0c3b1248e937a35ebe0f62521541a115f0e7de2 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Sun, 22 Mar 2026 06:50:36 -0600 Subject: [PATCH 08/17] uman: Add --bt and --gdb-cmd options to test gdb support When debugging a crash it is useful to get an automatic backtrace without having to interact with gdb. Also, other gdb commands may be useful after a test stops. Add --bt to automatically run 'bt' and 'quit' after the program stops, providing a quick backtrace on segfault. Add --gdb-cmd as a repeatable option to pass arbitrary -ex commands to gdb after the 'run'. Both options imply -g. Co-developed-by: Claude Opus 4.6 (1M context) --- README.rst | 2 ++ uman_pkg/cmdline.py | 6 ++++++ uman_pkg/cmdtest.py | 6 +++++- uman_pkg/ftest.py | 51 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index c890fb8..1d9b407 100644 --- a/README.rst +++ b/README.rst @@ -863,8 +863,10 @@ without going through pytest. This is faster for quick iteration on C code. - ``-B, --board BOARD``: Board to build/test (default: sandbox) - ``-f, --force-reconfig``: Force reconfiguration (use with -b) - ``-F, --fresh``: Delete build dir before building (use with -b) +- ``--bt``: Show backtrace on crash and exit (implies -g) - ``--flattree-too``: Run both live-tree and flat-tree tests (default: live-tree only) - ``-g, --gdb``: Run sandbox under gdb-multiarch +- ``--gdb-cmd CMD``: GDB command to run after the test (repeatable; implies -g) - ``-j, --jobs JOBS``: Number of parallel jobs (use with -b) - ``-l, --list``: List available tests - ``-L, --lto``: Enable LTO when building (use with -b) diff --git a/uman_pkg/cmdline.py b/uman_pkg/cmdline.py index ca3043a..3546ed6 100644 --- a/uman_pkg/cmdline.py +++ b/uman_pkg/cmdline.py @@ -394,9 +394,15 @@ def add_test_subparser(subparsers): test.add_argument( '-b', '--build', action='store_true', help='Build before running tests') + test.add_argument( + '--bt', action='store_true', + help='Show backtrace on crash and exit (implies -g)') test.add_argument( '-g', '--gdb', action='store_true', help='Run sandbox under gdb-multiarch') + test.add_argument( + '--gdb-cmd', metavar='CMD', action='append', default=[], + help='GDB command to run after the test (repeatable; implies -g)') test.add_argument( '-B', '--board', metavar='BOARD', default='sandbox', help='Board to build/test (default: sandbox)') diff --git a/uman_pkg/cmdtest.py b/uman_pkg/cmdtest.py index c5c413d..b9d900e 100644 --- a/uman_pkg/cmdtest.py +++ b/uman_pkg/cmdtest.py @@ -909,6 +909,10 @@ def run_gdb(cmd, args): '-iex', 'handle SIGUSR2 nostop noprint pass', '-ex', f'run {run_args}', ] + for extra in args.gdb_cmd: + gdb_cmd.extend(['-ex', extra]) + if args.bt: + gdb_cmd.extend(['-ex', 'bt', '-ex', 'quit']) if args.dry_run: tout.notice(shlex.join(gdb_cmd)) @@ -940,7 +944,7 @@ def run_tests(sandbox, specs, args, col): malloc_dump=args.malloc_dump, leak_check=args.leak_check) - if args.gdb: + if args.gdb or args.bt or args.gdb_cmd: return run_gdb(cmd, args) if args.dry_run: diff --git a/uman_pkg/ftest.py b/uman_pkg/ftest.py index fecef7d..fdd7a63 100644 --- a/uman_pkg/ftest.py +++ b/uman_pkg/ftest.py @@ -6041,6 +6041,57 @@ def test_run_tests_gdb_dry_run(self): self.assertIn('gdb-multiarch', output) self.assertIn('/sb', output) + def test_run_tests_gdb_bt(self): + """Test run_tests with --bt adds backtrace and quit commands""" + cap = [] + + def mock_run(cmd, **kwargs): + cap.append((cmd, kwargs)) + return mock.MagicMock(returncode=0) + + args = cmdline.parse_args(['test', '--bt', 'dm']) + col = terminal.Color() + with mock.patch.object(cmdtest, 'has_emit_result', + return_value=True): + with mock.patch.object(cmdtest, 'has_no_flat', + return_value=True): + with mock.patch.object(cmdtest, 'ensure_dm_init_files', + return_value=True): + with mock.patch('subprocess.run', mock_run): + with terminal.capture(): + result = cmdtest.run_tests( + '/sb', [('dm', None)], args, col) + self.assertEqual(0, result) + cmd = cap[0][0] + self.assertEqual('gdb-multiarch', cmd[0]) + self.assertEqual(['-ex', 'bt', '-ex', 'quit'], cmd[-4:]) + + def test_run_tests_gdb_cmd(self): + """Test run_tests with --gdb-cmd adds extra gdb commands""" + cap = [] + + def mock_run(cmd, **kwargs): + cap.append((cmd, kwargs)) + return mock.MagicMock(returncode=0) + + args = cmdline.parse_args(['test', '--gdb-cmd', 'info reg', + '--gdb-cmd', 'bt', 'dm']) + col = terminal.Color() + with mock.patch.object(cmdtest, 'has_emit_result', + return_value=True): + with mock.patch.object(cmdtest, 'has_no_flat', + return_value=True): + with mock.patch.object(cmdtest, 'ensure_dm_init_files', + return_value=True): + with mock.patch('subprocess.run', mock_run): + with terminal.capture(): + result = cmdtest.run_tests( + '/sb', [('dm', None)], args, col) + self.assertEqual(0, result) + cmd = cap[0][0] + self.assertEqual('gdb-multiarch', cmd[0]) + self.assertEqual(['-ex', 'info reg', '-ex', 'bt'], cmd[-4:]) + class TestPytestCTest(TestBase): """Tests for the pytest -C (C test) functionality""" From 6e61225da1314a839bde3dda2772082a87724cd4 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Tue, 24 Mar 2026 14:46:04 -0600 Subject: [PATCH 09/17] uman: Add -f option to config to find function source locations When debugging or navigating a U-Boot build, it is useful to quickly find where a function is defined without searching the source tree manually. Add -f/--find to the config subcommand. It uses nm to find matching symbols in the u-boot binary and addr2line to resolve each to a source file and line number. The pattern is a substring match, so '-f do_mem' shows all functions containing 'do_mem'. Co-developed-by: Claude Opus 4.6 (1M context) --- README.rst | 5 ++++ uman_pkg/cmdconfig.py | 53 +++++++++++++++++++++++++++++++++++- uman_pkg/cmdline.py | 3 +++ uman_pkg/ftest.py | 63 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 1d9b407..cf0f2bf 100644 --- a/README.rst +++ b/README.rst @@ -890,6 +890,10 @@ Config Subcommand The ``config`` command (alias ``cfg``) provides tools for examining and modifying U-Boot configuration:: + # Find a function's source location + uman config -B sandbox -f do_version + um cfg -f do_mem + # Grep .config for a pattern (case-insensitive regex) uman config -B sandbox -g VIDEO um cfg -g DM_TEST @@ -909,6 +913,7 @@ for interactive comparison instead of copying. **Options**: - ``-B, --board BOARD``: Board name (required; or set ``$b``) +- ``-f, --find FUNC``: Find function in binary and show source file:line - ``-g, --grep PATTERN``: Grep .config for PATTERN (regex, case-insensitive) - ``-m, --meld``: Compare defconfig with meld - ``-s, --sync``: Resync defconfig from .config diff --git a/uman_pkg/cmdconfig.py b/uman_pkg/cmdconfig.py index 23b1e92..b1b5511 100644 --- a/uman_pkg/cmdconfig.py +++ b/uman_pkg/cmdconfig.py @@ -13,6 +13,7 @@ import shutil # pylint: disable=import-error +from u_boot_pylib import command from u_boot_pylib import tout from uman_pkg import build as build_mod @@ -36,6 +37,53 @@ def get_config_path(board, build_dir=None): return os.path.join(build_dir, '.config') +def do_find(args): + """Find a function in the binary and show its source file and line + + Uses nm to look up function symbols matching the pattern, then + addr2line to resolve each to a source location. + + Args: + args (argparse.Namespace): Arguments from cmdline + + Returns: + int: Exit code (0 for success, non-zero for failure) + """ + board = args.board or os.environ.get('b') + if not board: + tout.error('Board is required: use -B BOARD or set $b') + return 1 + + build_dir = args.build_dir or build_mod.get_dir(board) + binary = os.path.join(build_dir, 'u-boot') + if not os.path.exists(binary): + tout.error(f'Binary not found: {binary}') + tout.error(f'Build the board first: um b {board}') + return 1 + + pattern = args.find + result = command.run_one('nm', binary, capture=True) + matches = [] + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) == 3 and parts[1] in 'TtWw': + if pattern in parts[2]: + matches.append((parts[0], parts[2])) + + if not matches: + tout.error(f'No functions matching: {pattern}') + return 1 + + addrs = [addr for addr, _ in matches] + result = command.run_one('addr2line', '-e', binary, *addrs, + capture=True) + lines = result.stdout.strip().splitlines() + for (_, name), loc in zip(matches, lines): + print(f'{name}: {loc}') + + return 0 + + def do_grep(args): """Grep the .config file for a pattern @@ -156,6 +204,9 @@ def run(args): Returns: int: Exit code """ + if args.find: + return do_find(args) + if args.grep: return do_grep(args) @@ -165,5 +216,5 @@ def run(args): if args.sync: return do_sync(args) - tout.error('No action specified (use -g PATTERN, -m, or -s)') + tout.error('No action specified (use -f FUNC, -g PATTERN, -m, or -s)') return 1 diff --git a/uman_pkg/cmdline.py b/uman_pkg/cmdline.py index 3546ed6..67f4ca4 100644 --- a/uman_pkg/cmdline.py +++ b/uman_pkg/cmdline.py @@ -475,6 +475,9 @@ def add_config_subparser(subparsers): cfg.add_argument( '-B', '--board', metavar='BOARD', help='Board name (required; or set $b)') + cfg.add_argument( + '-f', '--find', metavar='FUNC', + help='Find function in binary and show source file:line') cfg.add_argument( '-g', '--grep', metavar='PATTERN', help='Grep .config for PATTERN (regex, case-insensitive)') diff --git a/uman_pkg/ftest.py b/uman_pkg/ftest.py index fdd7a63..d121f20 100644 --- a/uman_pkg/ftest.py +++ b/uman_pkg/ftest.py @@ -998,6 +998,69 @@ def mock_exec_cmd(cmd, dry_run=False, env=None, capture=True): self.assertIn('savedefconfig', cap[1]) self.assertEqual('meld', cap[2][0]) + def test_find_function(self): + """Test finding a function in the binary""" + nm_output = ('0000000000073fb2 t do_version\n' + '0000000000073fc0 T do_version_cmd\n' + '00000000000886c5 t do_mem_md\n') + addr2line_output = ('cmd/version.c:18\n' + 'cmd/version.c:30\n') + binary = os.path.join(self.build_dir, 'u-boot') + with open(binary, 'w', encoding='utf-8') as outf: + outf.write('') + + def mock_run(*cmd_args, **kwargs): + if cmd_args[0] == 'nm': + return command.CommandResult(return_code=0, + stdout=nm_output) + return command.CommandResult(return_code=0, + stdout=addr2line_output) + + args = cmdline.parse_args(['config', '-B', 'sandbox', '-f', + 'do_version', '--build-dir', + self.build_dir]) + with mock.patch.object(command, 'run_one', mock_run): + with terminal.capture() as (out, err): + ret = cmdconfig.do_find(args) + + self.assertEqual(0, ret) + self.assertEqual('do_version: cmd/version.c:18\n' + 'do_version_cmd: cmd/version.c:30\n', + out.getvalue()) + self.assertFalse(err.getvalue()) + + def test_find_function_no_match(self): + """Test finding a function with no matches""" + nm_output = '0000000000073fb2 t do_version\n' + binary = os.path.join(self.build_dir, 'u-boot') + with open(binary, 'w', encoding='utf-8') as outf: + outf.write('') + + def mock_run(*cmd_args, **kwargs): + return command.CommandResult(return_code=0, stdout=nm_output) + + args = cmdline.parse_args(['config', '-B', 'sandbox', '-f', + 'nonexistent', '--build-dir', + self.build_dir]) + with mock.patch.object(command, 'run_one', mock_run): + with terminal.capture() as (out, err): + ret = cmdconfig.do_find(args) + + self.assertEqual(1, ret) + self.assertFalse(out.getvalue()) + + def test_find_function_no_binary(self): + """Test finding a function when binary does not exist""" + args = cmdline.parse_args(['config', '-B', 'sandbox', '-f', + 'do_version', '--build-dir', + self.build_dir]) + # Remove the build dir contents (no u-boot binary) + with terminal.capture() as (out, err): + ret = cmdconfig.do_find(args) + + self.assertEqual(1, ret) + self.assertFalse(out.getvalue()) + class TestGitSubcommand(TestBase): """Test git subcommand functionality""" From 8b97807faf5e1667ecf7a04168e59e1367c506b5 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Tue, 24 Mar 2026 14:53:58 -0600 Subject: [PATCH 10/17] uman: Add build options to the config subcommand The config subcommand cannot trigger a build before its actions, so the user must manually build first before using -f or -g. Add -b/--build and the common build options (via add_build_opts()) to the config subparser. This allows e.g. 'um cfg -b -f do_version' to build and then look up the function in one step. The add_build_opts() helper gains a skip_short parameter so that subparsers with conflicting short flags (here -f is used by --find) can exclude them while keeping the long form available. The config-specific --build-dir is replaced by the common -o/--output-dir from add_build_opts() to avoid duplication. Co-developed-by: Claude Opus 4.6 (1M context) --- README.rst | 4 +++- uman_pkg/cmdconfig.py | 20 +++++++++++++++++--- uman_pkg/cmdline.py | 15 ++++++++++----- uman_pkg/ftest.py | 21 +++++++++------------ 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/README.rst b/README.rst index cf0f2bf..a0ad277 100644 --- a/README.rst +++ b/README.rst @@ -912,12 +912,14 @@ for interactive comparison instead of copying. **Options**: +- ``-b, --build``: Build before running the config action - ``-B, --board BOARD``: Board name (required; or set ``$b``) - ``-f, --find FUNC``: Find function in binary and show source file:line - ``-g, --grep PATTERN``: Grep .config for PATTERN (regex, case-insensitive) - ``-m, --meld``: Compare defconfig with meld - ``-s, --sync``: Resync defconfig from .config -- ``--build-dir DIR``: Override build directory +- Plus common build options (``-a``, ``--force-reconfig``, ``-F``, ``-j``, + ``-L``, ``-o``, ``-T``, ``--no-trace-early``); use with ``-b`` Build Subcommand ---------------- diff --git a/uman_pkg/cmdconfig.py b/uman_pkg/cmdconfig.py index b1b5511..422cd87 100644 --- a/uman_pkg/cmdconfig.py +++ b/uman_pkg/cmdconfig.py @@ -54,7 +54,7 @@ def do_find(args): tout.error('Board is required: use -B BOARD or set $b') return 1 - build_dir = args.build_dir or build_mod.get_dir(board) + build_dir = args.output_dir or build_mod.get_dir(board) binary = os.path.join(build_dir, 'u-boot') if not os.path.exists(binary): tout.error(f'Binary not found: {binary}') @@ -98,7 +98,7 @@ def do_grep(args): tout.error('Board is required: use -B BOARD or set $b') return 1 - config_path = get_config_path(board, args.build_dir) + config_path = get_config_path(board, args.output_dir) if not os.path.exists(config_path): tout.error(f'Config file not found: {config_path}') tout.error(f'Build the board first: um b {board}') @@ -142,7 +142,7 @@ def do_sync(args, use_meld=False): tout.error('Not in a U-Boot tree and $USRC not set') return 1 - build_dir = args.build_dir or build_mod.get_dir(board) + build_dir = args.output_dir or build_mod.get_dir(board) defconfig_path = os.path.join(uboot_dir, 'configs', f'{board}_defconfig') # Change to U-Boot directory for make @@ -204,6 +204,20 @@ def run(args): Returns: int: Exit code """ + if args.build: + board = args.board or os.environ.get('b') + if not board: + tout.error('Board is required: use -B BOARD or set $b') + return 1 + if not build_mod.build_board( + board, args.dry_run, lto=args.lto, + adjust_cfg=args.adjust_cfg, + force_reconfig=args.force_reconfig, fresh=args.fresh, + jobs=args.jobs, trace=args.trace, + trace_early=not args.no_trace_early, + output_dir=args.output_dir): + return 1 + if args.find: return do_find(args) diff --git a/uman_pkg/cmdline.py b/uman_pkg/cmdline.py index 67f4ca4..cc4c5fc 100644 --- a/uman_pkg/cmdline.py +++ b/uman_pkg/cmdline.py @@ -224,18 +224,22 @@ def add_test_opts(parser, board_help=None, board_default=None): add_leak_opts(parser) -def add_build_opts(parser): +def add_build_opts(parser, skip_short=None): """Add common build options to a parser Args: parser: Argument parser to add options to + skip_short (set or None): Short flags to omit (e.g. {'-f'}) when + they conflict with other options on the same subparser """ + skip = skip_short or set() group = parser.add_argument_group('build options') group.add_argument( '-a', '--adjust-cfg', action='append', metavar='CFG', dest='adjust_cfg', help='Adjust Kconfig setting (use with -b; can use multiple times)') + flags = ['-f', '--force-reconfig'] if '-f' not in skip else ['--force-reconfig'] group.add_argument( - '-f', '--force-reconfig', action='store_true', + *flags, action='store_true', help='Force reconfiguration (use with -b)') group.add_argument( '-F', '--fresh', action='store_true', @@ -472,6 +476,9 @@ def add_config_subparser(subparsers): cfg = subparsers.add_parser( 'config', aliases=['cfg'], help='Examine U-Boot configuration') + cfg.add_argument( + '-b', '--build', action='store_true', + help='Build before running the config action') cfg.add_argument( '-B', '--board', metavar='BOARD', help='Board name (required; or set $b)') @@ -487,9 +494,7 @@ def add_config_subparser(subparsers): cfg.add_argument( '-s', '--sync', action='store_true', help='Resync defconfig from .config (build cfg, savedefconfig, copy)') - cfg.add_argument( - '--build-dir', metavar='DIR', - help='Override build directory (default: /tmp/b/BOARD)') + add_build_opts(cfg, skip_short={'-f'}) return cfg diff --git a/uman_pkg/ftest.py b/uman_pkg/ftest.py index d121f20..534e0ca 100644 --- a/uman_pkg/ftest.py +++ b/uman_pkg/ftest.py @@ -864,7 +864,7 @@ def test_config_alias(self): def test_config_grep(self): """Test config grep finds matches""" args = cmdline.parse_args(['config', '-B', 'sandbox', '-g', 'VIDEO', - '--build-dir', self.build_dir]) + '-o', self.build_dir]) with terminal.capture() as (out, _): ret = cmdconfig.run(args) self.assertEqual(0, ret) @@ -874,7 +874,7 @@ def test_config_grep(self): def test_config_grep_case_insensitive(self): """Test config grep is case-insensitive""" args = cmdline.parse_args(['config', '-B', 'sandbox', '-g', 'video', - '--build-dir', self.build_dir]) + '-o', self.build_dir]) with terminal.capture() as (out, _): ret = cmdconfig.run(args) self.assertEqual(0, ret) @@ -884,7 +884,7 @@ def test_config_grep_no_match(self): """Test config grep with no matches""" args = cmdline.parse_args( ['config', '-B', 'sandbox', '-g', 'NONEXISTENT', - '--build-dir', self.build_dir]) + '-o', self.build_dir]) with terminal.capture() as (out, _): ret = cmdconfig.run(args) self.assertEqual(0, ret) @@ -914,7 +914,7 @@ def test_config_no_action(self): def test_config_missing_config_file(self): """Test config fails when .config not found""" args = cmdline.parse_args(['config', '-B', 'sandbox', '-g', 'VIDEO', - '--build-dir', '/nonexistent/path']) + '-o', '/nonexistent/path']) with terminal.capture() as (_, err): ret = cmdconfig.run(args) self.assertEqual(1, ret) @@ -936,7 +936,7 @@ def mock_exec_cmd(cmd, dry_run=False, env=None, capture=True): return command.CommandResult(return_code=0) args = cmdline.parse_args(['config', '-B', 'sandbox', '-s', - '--build-dir', self.build_dir]) + '-o', self.build_dir]) # Create defconfig in build dir for copy with open(os.path.join(self.build_dir, 'defconfig'), 'w', encoding='utf-8') as outf: @@ -975,7 +975,7 @@ def mock_exec_cmd(cmd, dry_run=False, env=None, capture=True): return command.CommandResult(return_code=0) args = cmdline.parse_args(['config', '-B', 'sandbox', '-m', - '--build-dir', self.build_dir]) + '-o', self.build_dir]) # Create defconfig in build dir with open(os.path.join(self.build_dir, 'defconfig'), 'w', encoding='utf-8') as outf: @@ -1017,8 +1017,7 @@ def mock_run(*cmd_args, **kwargs): stdout=addr2line_output) args = cmdline.parse_args(['config', '-B', 'sandbox', '-f', - 'do_version', '--build-dir', - self.build_dir]) + 'do_version', '-o', self.build_dir]) with mock.patch.object(command, 'run_one', mock_run): with terminal.capture() as (out, err): ret = cmdconfig.do_find(args) @@ -1040,8 +1039,7 @@ def mock_run(*cmd_args, **kwargs): return command.CommandResult(return_code=0, stdout=nm_output) args = cmdline.parse_args(['config', '-B', 'sandbox', '-f', - 'nonexistent', '--build-dir', - self.build_dir]) + 'nonexistent', '-o', self.build_dir]) with mock.patch.object(command, 'run_one', mock_run): with terminal.capture() as (out, err): ret = cmdconfig.do_find(args) @@ -1052,8 +1050,7 @@ def mock_run(*cmd_args, **kwargs): def test_find_function_no_binary(self): """Test finding a function when binary does not exist""" args = cmdline.parse_args(['config', '-B', 'sandbox', '-f', - 'do_version', '--build-dir', - self.build_dir]) + 'do_version', '-o', self.build_dir]) # Remove the build dir contents (no u-boot binary) with terminal.capture() as (out, err): ret = cmdconfig.do_find(args) From cc2eb84ca2a4c29467d4191f2df4e150423dd639 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Tue, 24 Mar 2026 14:56:10 -0600 Subject: [PATCH 11/17] uman: Strip source prefix from cfg -f output The DWARF paths in the binary come from the build environment, which may differ from the local source tree (e.g. when building in a container). This makes the output hard to use for navigation. Strip the build prefix by finding the longest relative suffix of each addr2line path that exists as a file in the local source tree. When no source tree is available, the full path is shown as before. Co-developed-by: Claude Opus 4.6 (1M context) --- uman_pkg/cmdconfig.py | 33 ++++++++++++++++++++++++++++++++- uman_pkg/ftest.py | 17 +++++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/uman_pkg/cmdconfig.py b/uman_pkg/cmdconfig.py index 422cd87..753bb51 100644 --- a/uman_pkg/cmdconfig.py +++ b/uman_pkg/cmdconfig.py @@ -37,6 +37,36 @@ def get_config_path(board, build_dir=None): return os.path.join(build_dir, '.config') +def strip_src_prefix(loc, src_dir): + """Strip the source-tree prefix from an addr2line location + + The binary may have been built in a different directory (e.g. a + container), so the DWARF paths won't match the local source tree. + Walk the path components to find the longest relative suffix that + exists as a file in src_dir. + + Args: + loc (str): Location string from addr2line (e.g. + '/home/ubuntu/project/cmd/version.c:18') + src_dir (str or None): Local source directory, or None + + Returns: + str: Relative path if found, otherwise the original loc + """ + if not src_dir or ':' not in loc: + return loc + + # Split off the :line suffix + path, sep, line = loc.rpartition(':') + # Try progressively shorter prefixes + parts = path.split('/') + for i in range(1, len(parts)): + rel = '/'.join(parts[i:]) + if os.path.exists(os.path.join(src_dir, rel)): + return f'{rel}{sep}{line}' + return loc + + def do_find(args): """Find a function in the binary and show its source file and line @@ -77,9 +107,10 @@ def do_find(args): addrs = [addr for addr, _ in matches] result = command.run_one('addr2line', '-e', binary, *addrs, capture=True) + src_dir = get_uboot_dir() lines = result.stdout.strip().splitlines() for (_, name), loc in zip(matches, lines): - print(f'{name}: {loc}') + print(f'{name}: {strip_src_prefix(loc, src_dir)}') return 0 diff --git a/uman_pkg/ftest.py b/uman_pkg/ftest.py index 534e0ca..b7dc22f 100644 --- a/uman_pkg/ftest.py +++ b/uman_pkg/ftest.py @@ -1000,11 +1000,18 @@ def mock_exec_cmd(cmd, dry_run=False, env=None, capture=True): def test_find_function(self): """Test finding a function in the binary""" + # Create a source tree with cmd/version.c so prefix stripping works + src_dir = os.path.join(self.test_dir, 'src') + os.makedirs(os.path.join(src_dir, 'cmd')) + with open(os.path.join(src_dir, 'cmd', 'version.c'), 'w', + encoding='utf-8') as outf: + outf.write('') + nm_output = ('0000000000073fb2 t do_version\n' '0000000000073fc0 T do_version_cmd\n' '00000000000886c5 t do_mem_md\n') - addr2line_output = ('cmd/version.c:18\n' - 'cmd/version.c:30\n') + addr2line_output = ('/build/env/cmd/version.c:18\n' + '/build/env/cmd/version.c:30\n') binary = os.path.join(self.build_dir, 'u-boot') with open(binary, 'w', encoding='utf-8') as outf: outf.write('') @@ -1019,8 +1026,10 @@ def mock_run(*cmd_args, **kwargs): args = cmdline.parse_args(['config', '-B', 'sandbox', '-f', 'do_version', '-o', self.build_dir]) with mock.patch.object(command, 'run_one', mock_run): - with terminal.capture() as (out, err): - ret = cmdconfig.do_find(args) + with mock.patch.object(cmdconfig, 'get_uboot_dir', + return_value=src_dir): + with terminal.capture() as (out, err): + ret = cmdconfig.do_find(args) self.assertEqual(0, ret) self.assertEqual('do_version: cmd/version.c:18\n' From 000ff8730c9ba116c42d77746735fd64fdd8fda7 Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Wed, 25 Mar 2026 08:04:06 -0600 Subject: [PATCH 12/17] uman: Add --bt and --gdb-cmd options to the py subcommand The test subcommand supports --bt and -x/--gdb-cmd for passing extra commands to gdb, but the py subcommand does not. Add the same options to the py subparser. Since -x is already taken by --exitfirst in the shared test options, only the long form --gdb-cmd is available for py. Both --bt and --gdb-cmd imply -G, so a separate -g session is still needed in another terminal. Also fix make_args() in tests to include leak_check, bt, and gdb_cmd fields, fixing 7 pre-existing test failures. Co-developed-by: Claude Opus 4.6 (1M context) --- README.rst | 2 ++ uman_pkg/cmdline.py | 7 +++++++ uman_pkg/cmdpy.py | 8 ++++++++ uman_pkg/ftest.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/README.rst b/README.rst index a0ad277..925bebf 100644 --- a/README.rst +++ b/README.rst @@ -702,7 +702,9 @@ hooks to PATH. - ``--find PATTERN``: Find tests matching PATTERN and show full IDs - ``--force-reconfig``: Force reconfiguration (use with -b) - ``--fresh``: Delete build dir before building (use with -b) +- ``--bt``: Show backtrace on crash and exit (implies -G) - ``-g``: Run sandbox under gdbserver at localhost:1234 +- ``--gdb-cmd CMD``: GDB command to run after connecting (repeatable; implies -G) - ``--gdb-phase PHASE``: Debug a specific phase (spl, tpl, vpl) - ``-G, --gdb``: Launch gdb-multiarch and connect to an existing gdbserver - ``-j, --jobs JOBS``: Number of parallel jobs (use with -b) diff --git a/uman_pkg/cmdline.py b/uman_pkg/cmdline.py index cc4c5fc..02be1c3 100644 --- a/uman_pkg/cmdline.py +++ b/uman_pkg/cmdline.py @@ -283,9 +283,16 @@ def add_pytest_subparser(subparsers): pyt.add_argument( '--find', metavar='PATTERN', help='Find tests matching PATTERN and show full IDs') + pyt.add_argument( + '--bt', action='store_true', + help='Show backtrace on crash and exit (implies -G)') pyt.add_argument( '-G', '--gdb', action='store_true', help='Launch gdb client (connect to existing gdbserver from -g)') + pyt.add_argument( + '--gdb-cmd', metavar='CMD', action='append', default=[], + help='GDB command to run after connecting (e.g. --gdb-cmd bt); ' + 'repeatable, implies -G') pyt.add_argument( '-l', '--list', action='store_true', dest='list_boards', help='List available QEMU boards') diff --git a/uman_pkg/cmdpy.py b/uman_pkg/cmdpy.py index 98df164..7186434 100644 --- a/uman_pkg/cmdpy.py +++ b/uman_pkg/cmdpy.py @@ -1141,6 +1141,10 @@ def run_with_gdb(args): '-iex', 'handle SIGUSR2 nostop noprint pass', # Used by sandbox coroutines '-ex', f'target remote {channel}', ] + for extra in args.gdb_cmd: + gdb_cmd.extend(['-ex', extra]) + if args.bt: + gdb_cmd.extend(['-ex', 'bt', '-ex', 'quit']) if args.dry_run: print(' '.join(gdb_cmd)) @@ -1493,6 +1497,10 @@ def do_pytest(args): # pylint: disable=too-many-return-statements,too-many-bran tout.notice('Try: uman setup qemu') return 1 + # Handle --bt / --gdb-cmd implying -G + if (args.bt or args.gdb_cmd) and not args.gdb: + args.gdb = True + # Handle -G: set gdb_phase if not already set if args.gdb and not args.gdb_phase: args.gdb_phase = 'u-boot' diff --git a/uman_pkg/ftest.py b/uman_pkg/ftest.py index b7dc22f..45699fa 100644 --- a/uman_pkg/ftest.py +++ b/uman_pkg/ftest.py @@ -118,6 +118,7 @@ def make_args(**kwargs): 'all': False, 'bisect': None, 'board': None, + 'bt': False, 'build': False, 'build_dir': None, 'c_test': False, @@ -133,9 +134,11 @@ def make_args(**kwargs): 'flattree_too': False, 'fresh': False, 'gdb': False, + 'gdb_cmd': [], 'gdb_phase': None, 'gdbserver': None, 'jobs': None, + 'leak_check': False, 'list_boards': False, 'lto': False, 'malloc_dump': None, @@ -3531,6 +3534,47 @@ def test_pytest_gdb_dry_run(self): self.assertIn('target remote localhost:1234', output) self.assertIn('handle SIGUSR2 nostop noprint pass', output) + def test_pytest_gdb_bt_dry_run(self): + """Test pytest --bt with dry-run adds bt and quit to gdb command""" + args = make_args(cmd='pytest', board='sandbox', + gdb_phase='u-boot', + gdb=True, bt=True, dry_run=True) + + with terminal.capture() as (out, _): + res = control.run_command(args) + + self.assertEqual(0, res) + output = out.getvalue() + self.assertIn('gdb-multiarch', output) + self.assertIn('-ex bt -ex quit', output) + + def test_pytest_gdb_cmd_dry_run(self): + """Test pytest --gdb-cmd with dry-run adds extra gdb commands""" + args = make_args(cmd='pytest', board='sandbox', + gdb_phase='u-boot', + gdb=True, gdb_cmd=['info reg', 'bt'], + dry_run=True) + + with terminal.capture() as (out, _): + res = control.run_command(args) + + self.assertEqual(0, res) + output = out.getvalue() + self.assertIn('gdb-multiarch', output) + self.assertIn('-ex info reg -ex bt', output) + + def test_pytest_bt_implies_gdb(self): + """Test pytest --bt implies -G""" + args = make_args(cmd='pytest', board='sandbox', + bt=True, dry_run=True) + + with terminal.capture() as (out, _): + res = control.run_command(args) + + self.assertEqual(0, res) + output = out.getvalue() + self.assertIn('gdb-multiarch', output) + def test_get_uboot_dir_current(self): """Test get_uboot_dir finds U-Boot in current directory""" # setUp already created fake U-Boot tree in self.test_dir From 255939103f74243eb20d1ddbdd23612c619d434b Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Wed, 25 Mar 2026 08:36:08 -0600 Subject: [PATCH 13/17] uman: Allow the CI remote to be configured or auto-detected The CI remote is hardcoded to 'ci', so pushing to a different GitLab instance (e.g. mainline U-Boot) requires manually specifying the remote each time. Add a multi-level lookup for the CI remote: first -r/--remote on the command line, then the ci_remote setting, then auto-detection from the branch's ancestry. Auto-detection checks the branch's upstream tracking ref, then walks the commit history looking for the nearest remote tracking branch on a well-known branch (next, master, main). Since the detected upstream remote may not be the one to push to (e.g. 'us' is read-only), add a ci_remote_map setting that maps upstream remotes to push remotes (e.g. 'us:dm'). Falls back to 'ci' if nothing is found. Co-developed-by: Claude Opus 4.6 (1M context) --- README.rst | 9 ++++ uman_pkg/cmdline.py | 3 ++ uman_pkg/control.py | 95 +++++++++++++++++++++++++++++++++++++++--- uman_pkg/ftest.py | 99 ++++++++++++++++++++++++++++++++++++++++++++ uman_pkg/settings.py | 7 ++++ 5 files changed, 208 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 925bebf..e3b3420 100644 --- a/README.rst +++ b/README.rst @@ -130,6 +130,8 @@ Some simple examples:: - ``-l, --sjg [BOARD]``: Set SJG_LAB (optionally specify board) - ``-m, --merge``: Create merge request using cover letter from patch series - ``-p, --pytest [BOARD]``: Enable PYTEST (optionally specify board name) +- ``-r, --remote REMOTE``: Git remote to push to (default: ``ci_remote`` + setting or ``ci``) - ``-s, --suites``: Enable SUITES - ``-t, --test-spec SPEC``: Override test specification (e.g. "not sleep", "test_ofplatdata") @@ -1041,6 +1043,13 @@ Settings are stored in ``~/.uman`` (created on first run):: # Build directory for U-Boot out-of-tree builds build_dir = /tmp/b + # Git remote for CI pushes (default: ci); auto-detected from upstream + # ci_remote = ci + + # Map upstream remotes to push remotes (comma-separated from:to pairs) + # e.g. if upstream is 'us' but you push to 'dm': ci_remote_map = us:dm + # ci_remote_map = us:dm + # Directory for firmware blobs (OpenSBI, TF-A, etc.) blobs_dir = ~/dev/blobs diff --git a/uman_pkg/cmdline.py b/uman_pkg/cmdline.py index 02be1c3..4d97350 100644 --- a/uman_pkg/cmdline.py +++ b/uman_pkg/cmdline.py @@ -152,6 +152,9 @@ def add_ci_subparser(subparsers): help='Create merge request') ci.add_argument('-p', '--pytest', nargs='?', const='1', default=None, help=pytest_help) + ci.add_argument('-r', '--remote', metavar='REMOTE', default=None, + help='Git remote to push to (default: ci_remote setting ' + "or 'ci')") ci.add_argument('-s', '--suites', action='store_true', help='Enable SUITES') ci.add_argument('-t', '--test-spec', metavar='SPEC', diff --git a/uman_pkg/control.py b/uman_pkg/control.py index 2936d15..c13a642 100644 --- a/uman_pkg/control.py +++ b/uman_pkg/control.py @@ -122,8 +122,92 @@ def build_desc(desc, tags): return tags +def detect_upstream_remote(): + """Detect the CI remote from the branch's upstream + + First checks the branch's tracking ref (e.g. 'ci/master'). If that + is a local branch, walks the commit history looking for the first + remote tracking ref on a well-known branch (next, master, main). + + Returns: + str or None: Remote name, or None if not detectable + """ + # Try the tracking ref first + try: + upstream = command.output_one_line( + 'git', 'rev-parse', '--abbrev-ref', '@{u}', + raise_on_error=False) + except (command.CommandExc, ValueError): + upstream = None + if upstream and '/' in upstream: + return upstream.split('/')[0] + + # Walk commit history for the nearest remote/next or remote/master + import re # pylint: disable=import-outside-toplevel + + try: + result = command.run_one( + 'git', 'log', '--format=%D', + '--decorate-refs=refs/remotes/', '-50', capture=True, + raise_on_error=False) + except command.CommandExc: + return None + if not result or not result.stdout: + return None + for line in result.stdout.splitlines(): + match = re.search(r'(\w+)/(next|master|main)\b', line) + if match: + return match.group(1) + return None + + +def get_remote_map(): + """Parse the ci_remote_map setting into a dictionary + + The setting is a comma-separated list of from:to pairs, e.g. + 'us:dm,ub:ci'. + + Returns: + dict: Mapping of upstream remote to push remote + """ + raw = settings.get('ci_remote_map') + if not raw: + return {} + result = {} + for pair in raw.split(','): + pair = pair.strip() + if ':' in pair: + key, val = pair.split(':', 1) + result[key.strip()] = val.strip() + return result + + +def get_ci_remote(args): + """Get the CI remote name + + Uses -r/--remote if given, then the ci_remote setting, then + auto-detects from the branch's upstream tracking ref (applying the + ci_remote_map if set), falling back to 'ci'. + + Args: + args (argparse.Namespace): Command line arguments + + Returns: + str: Remote name + """ + if getattr(args, 'remote', None): + return args.remote + if settings.get('ci_remote'): + return settings.get('ci_remote') + detected = detect_upstream_remote() + if detected: + remote_map = get_remote_map() + return remote_map.get(detected, detected) + return 'ci' + + def git_push_branch(branch, args, ci_vars=None, upstream=False, dest=None): - """Push a branch to the 'ci' remote with optional CI variables + """Push a branch to the CI remote with optional CI variables Args: branch (str): Branch name to push @@ -137,6 +221,7 @@ def git_push_branch(branch, args, ci_vars=None, upstream=False, dest=None): Returns: CommandResult or None: Result of push command """ + remote = get_ci_remote(args) push_cmd = ['git', 'push'] if args.force: @@ -153,13 +238,13 @@ def git_push_branch(branch, args, ci_vars=None, upstream=False, dest=None): # args.dest, or current branch dest_branch = dest or args.dest or branch - # Always push to 'ci' remote, but to the specified destination branch + # Push to the CI remote if dest_branch == branch: # Same branch name, simple push - push_cmd.extend(['ci', branch]) + push_cmd.extend([remote, branch]) else: # Different branch name, use refspec - push_cmd.extend(['ci', f'{branch}:{dest_branch}']) + push_cmd.extend([remote, f'{branch}:{dest_branch}']) return exec_cmd(push_cmd, args.dry_run, capture=False) @@ -442,7 +527,7 @@ def do_merge_request(args): # pylint: disable=too-many-locals return 1 # Get remote URL and parse it using pickman's functions - remote_url = gitlab_api.get_remote_url('ci') + remote_url = gitlab_api.get_remote_url(get_ci_remote(args)) host, proj = gitlab_api.parse_url(remote_url) if not host or not proj: tout.error(f'Cannot parse remote URL: {remote_url}') diff --git a/uman_pkg/ftest.py b/uman_pkg/ftest.py index 45699fa..fd4c73a 100644 --- a/uman_pkg/ftest.py +++ b/uman_pkg/ftest.py @@ -150,6 +150,7 @@ def make_args(**kwargs): 'pollute': None, 'pytest': None, 'quiet': False, + 'remote': None, 'setup_only': False, 'show_cmd': False, 'show_output': False, @@ -2995,6 +2996,102 @@ def test_ci_no_ci_flag(self): '-o ci.variable=WORLD=0 -o ci.variable=SJG_LAB= ci master\n', out.getvalue()) + def test_ci_custom_remote(self): + """Test CI command with -r uses the specified remote""" + self._create_git_repo() + + args = make_args(dry_run=True, remote='upstream') + with terminal.capture() as (out, _): + res = control.do_ci(args) + self.assertEqual(0, res) + output = out.getvalue() + self.assertIn('upstream master', output) + self.assertNotIn(' ci ', output) + + def test_ci_remote_from_settings(self): + """Test CI command uses ci_remote from settings""" + self._create_git_repo() + + args = make_args(dry_run=True) + with mock.patch.object(settings, 'get', + side_effect=lambda k, **kw: + 'origin' if k == 'ci_remote' else + kw.get('fallback')): + with terminal.capture() as (out, _): + res = control.do_ci(args) + self.assertEqual(0, res) + output = out.getvalue() + self.assertIn('origin master', output) + + def test_ci_remote_auto_detect(self): + """Test CI command auto-detects remote from upstream tracking ref""" + self._create_git_repo() + + args = make_args(dry_run=True) + with mock.patch.object(control, 'detect_upstream_remote', + return_value='us'): + with terminal.capture() as (out, _): + res = control.do_ci(args) + self.assertEqual(0, res) + output = out.getvalue() + self.assertIn('us master', output) + + def test_detect_upstream_remote(self): + """Test detect_upstream_remote parses tracking ref""" + self._create_git_repo() + + # No upstream set - should return None + result = control.detect_upstream_remote() + self.assertIsNone(result) + + def test_detect_upstream_remote_from_history(self): + """Test detect_upstream_remote finds remote from commit history""" + log_output = ('el/loada-us, dm/loada-us\n' + 'us/next, us/WIP/23Mar2026-next\n') + result = command.CommandResult(return_code=0, stdout=log_output) + + with mock.patch.object(command, 'output_one_line', + side_effect=command.CommandExc('no upstream', + None)): + with mock.patch.object(command, 'run_one', + return_value=result): + remote = control.detect_upstream_remote() + self.assertEqual('us', remote) + + def test_ci_remote_map(self): + """Test CI remote mapping from upstream to push remote""" + self._create_git_repo() + + args = make_args(dry_run=True) + with mock.patch.object(control, 'detect_upstream_remote', + return_value='us'): + with mock.patch.object(settings, 'get', + side_effect=lambda k, **kw: + 'us:dm' if k == 'ci_remote_map' else + kw.get('fallback')): + with terminal.capture() as (out, _): + res = control.do_ci(args) + self.assertEqual(0, res) + output = out.getvalue() + self.assertIn('dm master', output) + + def test_ci_remote_map_no_match(self): + """Test CI remote mapping with no matching entry""" + self._create_git_repo() + + args = make_args(dry_run=True) + with mock.patch.object(control, 'detect_upstream_remote', + return_value='us'): + with mock.patch.object(settings, 'get', + side_effect=lambda k, **kw: + 'ub:ci' if k == 'ci_remote_map' else + kw.get('fallback')): + with terminal.capture() as (out, _): + res = control.do_ci(args) + self.assertEqual(0, res) + output = out.getvalue() + self.assertIn('us master', output) + def test_exec_cmd_dry_run(self): """Test exec_cmd in dry-run mode shows command""" with terminal.capture() as (out, err): @@ -3022,6 +3119,8 @@ def mock_exec(cmd, dry_run=False, env=None, capture=True): return command.CommandResult(stdout='', return_code=0) with mock.patch.object(control, 'exec_cmd', mock_exec): + with mock.patch.object(control, 'detect_upstream_remote', + return_value=None): # Test default destination (None means use current branch name) args = make_args(dry_run=False, dest=None) with terminal.capture(): diff --git a/uman_pkg/settings.py b/uman_pkg/settings.py index 9723938..0907786 100644 --- a/uman_pkg/settings.py +++ b/uman_pkg/settings.py @@ -20,6 +20,13 @@ # Build directory for U-Boot out-of-tree builds build_dir = /tmp/b +# Git remote for CI pushes (default: ci) +# ci_remote = ci + +# Map upstream remotes to push remotes (comma-separated from:to pairs) +# e.g. if upstream is 'us' but you push to 'dm': ci_remote_map = us:dm +# ci_remote_map = us:dm + # Directory for firmware blobs (OpenSBI, etc.) blobs_dir = ~/dev/blobs From e7d3bfc2da6f3889e2124a39edf7fa033c576e2b Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Wed, 25 Mar 2026 11:33:55 -0600 Subject: [PATCH 14/17] uman: Move -b/--build into add_build_opts() The -b/--build flag is defined separately in each subparser (test, py, cfg), so it appears in the main options section of the help output rather than with the other build options. Move it into add_build_opts() so it appears in the 'build options' group alongside -a, -f, -F, -j, -L, -o, -T and --no-trace-early. Co-developed-by: Claude Opus 4.6 (1M context) --- uman_pkg/cmdline.py | 12 +++--------- uman_pkg/ftest.py | 6 ++++-- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/uman_pkg/cmdline.py b/uman_pkg/cmdline.py index 4d97350..31e6d96 100644 --- a/uman_pkg/cmdline.py +++ b/uman_pkg/cmdline.py @@ -237,6 +237,9 @@ def add_build_opts(parser, skip_short=None): """ skip = skip_short or set() group = parser.add_argument_group('build options') + group.add_argument( + '-b', '--build', action='store_true', + help='Build before running') group.add_argument( '-a', '--adjust-cfg', action='append', metavar='CFG', dest='adjust_cfg', help='Adjust Kconfig setting (use with -b; can use multiple times)') @@ -271,9 +274,6 @@ def add_pytest_subparser(subparsers): help='Run pytest tests for U-Boot') add_test_opts(pyt, board_help='Board name to test (required; use -l to list QEMU boards)') - pyt.add_argument( - '-b', '--build', action='store_true', - help='Build U-Boot before running tests') pyt.add_argument( '-c', '--show-cmd', action='store_true', help='Show QEMU command line without running tests') @@ -405,9 +405,6 @@ def add_test_subparser(subparsers): test.add_argument( 'tests', nargs='*', metavar='TEST', help='Test name(s) to run (e.g. "dm" or "env")') - test.add_argument( - '-b', '--build', action='store_true', - help='Build before running tests') test.add_argument( '--bt', action='store_true', help='Show backtrace on crash and exit (implies -g)') @@ -486,9 +483,6 @@ def add_config_subparser(subparsers): cfg = subparsers.add_parser( 'config', aliases=['cfg'], help='Examine U-Boot configuration') - cfg.add_argument( - '-b', '--build', action='store_true', - help='Build before running the config action') cfg.add_argument( '-B', '--board', metavar='BOARD', help='Board name (required; or set $b)') diff --git a/uman_pkg/ftest.py b/uman_pkg/ftest.py index fd4c73a..e3ad6ac 100644 --- a/uman_pkg/ftest.py +++ b/uman_pkg/ftest.py @@ -3030,8 +3030,10 @@ def test_ci_remote_auto_detect(self): args = make_args(dry_run=True) with mock.patch.object(control, 'detect_upstream_remote', return_value='us'): - with terminal.capture() as (out, _): - res = control.do_ci(args) + with mock.patch.object(control, 'get_remote_map', + return_value={}): + with terminal.capture() as (out, _): + res = control.do_ci(args) self.assertEqual(0, res) output = out.getvalue() self.assertIn('us master', output) From a236e8dae03e38529ed8507696d32cc7d8ff859b Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Wed, 25 Mar 2026 11:37:45 -0600 Subject: [PATCH 15/17] uman: Fix -l help text in py subcommand The help text for -l says 'List available QEMU boards' but it actually lists QEMU, MicroBlaze, m68k, and sandbox boards. Update the help text to say 'List available QEMU and sandbox boards'. Co-developed-by: Claude Opus 4.6 (1M context) --- uman_pkg/cmdline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uman_pkg/cmdline.py b/uman_pkg/cmdline.py index 31e6d96..c9b9887 100644 --- a/uman_pkg/cmdline.py +++ b/uman_pkg/cmdline.py @@ -273,7 +273,7 @@ def add_pytest_subparser(subparsers): 'pytest', aliases=ALIASES['pytest'], help='Run pytest tests for U-Boot') add_test_opts(pyt, - board_help='Board name to test (required; use -l to list QEMU boards)') + board_help='Board name to test (required; use -l to list boards)') pyt.add_argument( '-c', '--show-cmd', action='store_true', help='Show QEMU command line without running tests') @@ -298,7 +298,7 @@ def add_pytest_subparser(subparsers): 'repeatable, implies -G') pyt.add_argument( '-l', '--list', action='store_true', dest='list_boards', - help='List available QEMU boards') + help='List available QEMU and sandbox boards') pyt.add_argument( '-P', '--persist', action='store_true', help='Persist test artifacts (do not clean up after tests)') From 544bbc64064de4402664344062ac19b2ada307ea Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Tue, 31 Mar 2026 10:11:50 -0400 Subject: [PATCH 16/17] uman: Allow commit hashes in rf, rp, rn, and rd These commands only accept numeric arguments, so editing a specific commit requires counting its position from HEAD or upstream. Allow a commit hash to be used instead: - rf : rebase from the parent of that commit, editing it first - rp : rebase to upstream, editing the matching commit - rn : during a rebase, skip to the matching commit in the todo - rd : diff against that commit directly For rp, a sequence editor matches the hash against todo lines. For rn, the hash is matched against the todo file inline. For rd, the hash is used directly with git difftool. Co-developed-by: Claude Opus 4.6 (1M context) --- uman_pkg/cmdgit.py | 146 +++++++++++++++++++++++++++++++++------------ uman_pkg/ftest.py | 38 +++++++++++- 2 files changed, 146 insertions(+), 38 deletions(-) diff --git a/uman_pkg/cmdgit.py b/uman_pkg/cmdgit.py index ffeac27..530318c 100644 --- a/uman_pkg/cmdgit.py +++ b/uman_pkg/cmdgit.py @@ -20,6 +20,25 @@ from uman_pkg.util import exec_cmd, git, git_output, git_output_quiet +def is_commit_hash(arg): + """Check if an argument looks like a commit hash rather than a number + + Args: + arg (str): Argument to check + + Returns: + bool: True if this looks like a commit hash + """ + if arg.isdigit(): + return False + # Accept hex strings (short or full SHA) and refs like HEAD~2 + try: + git_output_quiet('rev-parse', '--verify', f'{arg}^{{commit}}') + return True + except command.CommandExc: + return False + + def _count_breaks(path, fname): """Count 'break' lines in a rebase file @@ -148,6 +167,30 @@ def seq_edit_env(action, line=1): return env +def seq_edit_env_hash(commit): + """Create environment with GIT_SEQUENCE_EDITOR that edits by commit hash + + The editor finds the line starting with 'pick ' where hash is a + prefix of the given commit, and changes 'pick' to 'edit'. + + Args: + commit (str): Commit hash (short or full) + + Returns: + dict: Environment with GIT_SEQUENCE_EDITOR set + """ + env = os.environ.copy() + script = (f"import sys,re; p=sys.argv[1]; " + f"lines=open(p).readlines(); " + f"lines=[re.sub(r'^pick',r'edit',ln) " + f"if ln.split()[1].startswith('{commit}') " + f"or '{commit}'.startswith(ln.split()[1]) " + f"else ln for ln in lines]; " + f"open(p,'w').writelines(lines)") + env['GIT_SEQUENCE_EDITOR'] = f'python3 -c "{script}"' + return env + + def get_upstream(): """Get the upstream branch name @@ -280,7 +323,8 @@ def do_rf(args): Args: args (argparse.Namespace): Arguments from cmdline - args.arg: Number of commits back from HEAD, or None for upstream + args.arg: Number of commits back from HEAD, commit hash, or + None for upstream Returns: CommandResult or int: Result with return_code, stdout, stderr; or 0 @@ -290,7 +334,10 @@ def do_rf(args): return 1 if args.arg: - target = f'HEAD~{args.arg}' + if args.arg.isdigit(): + target = f'HEAD~{args.arg}' + else: + target = f'{args.arg}~1' else: target = get_upstream() if not target: @@ -306,17 +353,17 @@ def do_rf(args): def do_rp(args): - """Rebase to upstream, stop at patch N for editing + """Rebase to upstream, stop at patch N or commit hash for editing Args: args (argparse.Namespace): Arguments from cmdline - args.arg: Patch number (0 = upstream, before first commit) + args.arg: Patch number (0 = upstream), or commit hash Returns: CommandResult or int: Result with return_code, stdout, stderr; or 0 """ if args.arg is None: - tout.error('Patch number required: um git rp N') + tout.error('Patch number or commit hash required: um git rp N') return 1 target = get_upstream() @@ -324,11 +371,16 @@ def do_rp(args): tout.error('Cannot determine upstream branch') return 1 - patch_num = int(args.arg) - if patch_num == 0: - env = seq_edit_env('break') + if args.arg.isdigit(): + patch_num = int(args.arg) + if patch_num == 0: + env = seq_edit_env('break') + else: + env = seq_edit_env('edit', patch_num) else: - env = seq_edit_env('edit', patch_num) + # Commit hash: use a sequence editor that finds and edits it + commit = args.arg + env = seq_edit_env_hash(commit) result = git('rebase', '-i', target, env=env, dry_run=args.dry_run) if result is None: @@ -465,22 +517,40 @@ def do_rn(args): with open(todo_file, 'r', encoding='utf-8') as inf: lines = inf.readlines() - # Find non-comment lines - skip_count = int(args.arg) if args.arg else 1 - non_comment_indices = [] - for i, line in enumerate(lines): - if line.strip() and not line.startswith('#'): - non_comment_indices.append(i) - if len(non_comment_indices) >= skip_count: - break - - if non_comment_indices: - # Change the last one to 'edit' + # Find the target line to set to 'edit' + if args.arg and not args.arg.isdigit(): + # Commit hash: find the matching line + target_idx = None + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + parts = stripped.split() + if len(parts) >= 2: + h = parts[1] + if h.startswith(args.arg) or args.arg.startswith(h): + target_idx = i + break + if target_idx is None: + tout.error(f'Commit {args.arg} not found in rebase todo') + return 1 + else: + skip_count = int(args.arg) if args.arg else 1 + non_comment_indices = [] + for i, line in enumerate(lines): + if line.strip() and not line.startswith('#'): + non_comment_indices.append(i) + if len(non_comment_indices) >= skip_count: + break + if not non_comment_indices: + tout.error('No commits left in rebase todo') + return 1 target_idx = non_comment_indices[-1] - lines[target_idx] = re.sub(r'^\S+', 'edit', lines[target_idx]) - with open(todo_file, 'w', encoding='utf-8') as outf: - outf.writelines(lines) + lines[target_idx] = re.sub(r'^\S+', 'edit', lines[target_idx]) + + with open(todo_file, 'w', encoding='utf-8') as outf: + outf.writelines(lines) result = git('rebase', '--continue') show_rebase_status(result.stdout + result.stderr, result.return_code) @@ -687,28 +757,30 @@ def do_rd(args): with open(todo_file, 'r', encoding='utf-8') as inf: lines = inf.readlines() - # Parse args: if first arg is a digit, it's the commit number + # Parse args: digit = position, commit hash = direct, else file path extra = list(args.extra) if args.extra else [] + commit_hash = None if args.arg and args.arg.isdigit(): target = int(args.arg) + elif args.arg and is_commit_hash(args.arg): + commit_hash = args.arg else: target = 1 if args.arg: extra.insert(0, args.arg) - # Find the nth non-comment, non-empty line - count = 0 - commit_hash = None - for line in lines: - line = line.strip() - if line and not line.startswith('#'): - count += 1 - if count == target: - # Line format: "pick abc1234 commit message" - parts = line.split() - if len(parts) >= 2: - commit_hash = parts[1] - break + if not commit_hash: + # Find the nth non-comment, non-empty line + count = 0 + for line in lines: + line = line.strip() + if line and not line.startswith('#'): + count += 1 + if count == target: + parts = line.split() + if len(parts) >= 2: + commit_hash = parts[1] + break if not commit_hash: tout.error(f'No commit found at position {target}') diff --git a/uman_pkg/ftest.py b/uman_pkg/ftest.py index e3ad6ac..c52320c 100644 --- a/uman_pkg/ftest.py +++ b/uman_pkg/ftest.py @@ -2195,6 +2195,42 @@ def mock_git(*args, env=None, dry_run=None): self.assertEqual(0, result.return_code) self.assertEqual(('rebase', '-i', 'HEAD~5'), cap[0]) + def test_do_rf_with_hash(self): + """Test do_rf with a commit hash rebases from its parent""" + cap = [] + + def mock_git(*args, env=None, dry_run=None): + del env, dry_run + cap.append(args) + return mock.Mock(return_code=0, stdout='', stderr='') + + args = cmdline.parse_args(['git', 'rf', 'abc1234']) + with mock.patch('uman_pkg.cmdgit.git', mock_git): + with mock.patch.object(cmdgit, 'has_unstaged_changes', + return_value=False): + result = cmdgit.do_rf(args) + self.assertEqual(0, result.return_code) + self.assertEqual(('rebase', '-i', 'abc1234~1'), cap[0]) + + def test_do_rp_with_hash(self): + """Test do_rp with a commit hash""" + cap_env = [] + + def mock_git(*args, env=None, dry_run=None): + del args, dry_run + cap_env.append(env) + return mock.Mock(return_code=0, stdout='', stderr='') + + args = cmdline.parse_args(['git', 'rp', 'abc1234']) + with mock.patch('uman_pkg.cmdgit.git', mock_git): + with mock.patch.object(cmdgit, 'get_upstream', + return_value='origin/main'): + result = cmdgit.do_rp(args) + self.assertEqual(0, result.return_code) + editor = cap_env[0]['GIT_SEQUENCE_EDITOR'] + self.assertIn('abc1234', editor) + self.assertIn('edit', editor) + def test_do_rf_unstaged_changes(self): """Test do_rf fails with unstaged changes""" args = cmdline.parse_args(['git', 'rf']) @@ -2211,7 +2247,7 @@ def test_do_rp_requires_arg(self): with terminal.capture() as (_, err): result = cmdgit.do_rp(args) self.assertEqual(1, result) - self.assertIn('Patch number required', err.getvalue()) + self.assertIn('Patch number or commit hash required', err.getvalue()) class TestGitRebase(TestBase, GitRepoMixin): From 6051368a0335da47204b2e2e021a4da0dab2377e Mon Sep 17 00:00:00 2001 From: Simon Glass Date: Tue, 31 Mar 2026 10:19:56 -0400 Subject: [PATCH 17/17] uman: Show action list when 'um git' is run without arguments Running 'um git' with no action shows an error message, giving no indication of what actions are available. The -h output lists them but in a dense, hard-to-read format. Show a formatted table of all actions with their short name, long name, and description when no action is given. Co-developed-by: Claude Opus 4.6 (1M context) --- uman_pkg/cmdgit.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/uman_pkg/cmdgit.py b/uman_pkg/cmdgit.py index 530318c..aa56815 100644 --- a/uman_pkg/cmdgit.py +++ b/uman_pkg/cmdgit.py @@ -1531,8 +1531,10 @@ def run(args): return print_aliases() if not args.action: - tout.error('Action required (or use -a for aliases)') - return 1 + print('Available actions:') + for action in GIT_ACTIONS: + print(f' {action.short:5s} {action.long:20s} {action.name}') + return 0 # Resolve alias to short name action = ACTION_ALIASES.get(args.action, args.action)