diff --git a/README.rst b/README.rst index 94c7399..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") @@ -702,7 +704,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) @@ -863,7 +867,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) @@ -887,6 +894,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 @@ -905,11 +916,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 ---------------- @@ -1029,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/build.py b/uman_pkg/build.py index 26e221f..56dcfc9 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): @@ -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/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'): diff --git a/uman_pkg/cmdconfig.py b/uman_pkg/cmdconfig.py index 23b1e92..753bb51 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,84 @@ 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 + + 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.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}') + 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) + src_dir = get_uboot_dir() + lines = result.stdout.strip().splitlines() + for (_, name), loc in zip(matches, lines): + print(f'{name}: {strip_src_prefix(loc, src_dir)}') + + return 0 + + def do_grep(args): """Grep the .config file for a pattern @@ -50,7 +129,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}') @@ -94,7 +173,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 @@ -156,6 +235,23 @@ 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) + if args.grep: return do_grep(args) @@ -165,5 +261,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/cmdgit.py b/uman_pkg/cmdgit.py index ffeac27..aa56815 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}') @@ -1459,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) diff --git a/uman_pkg/cmdline.py b/uman_pkg/cmdline.py index 01440f9..c9b9887 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', @@ -224,18 +227,25 @@ 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( + '-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)') + 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', @@ -263,10 +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)') - pyt.add_argument( - '-b', '--build', action='store_true', - help='Build U-Boot before running tests') + 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') @@ -279,12 +286,19 @@ 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') + 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)') @@ -392,8 +406,14 @@ def add_test_subparser(subparsers): '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') + '--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)') @@ -466,6 +486,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)') @@ -475,9 +498,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/cmdpy.py b/uman_pkg/cmdpy.py index 7395166..7186434 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: @@ -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 @@ -199,12 +205,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 = [] @@ -387,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 @@ -401,6 +463,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 @@ -939,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 @@ -974,13 +1141,16 @@ 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)) 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): @@ -1157,8 +1327,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 +1433,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 @@ -1329,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' @@ -1342,21 +1514,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() @@ -1381,6 +1554,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/cmdtest.py b/uman_pkg/cmdtest.py index 7adf9d5..b9d900e 100644 --- a/uman_pkg/cmdtest.py +++ b/uman_pkg/cmdtest.py @@ -886,6 +886,43 @@ 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}', + ] + 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)) + 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 +944,9 @@ def run_tests(sandbox, specs, args, col): malloc_dump=args.malloc_dump, leak_check=args.leak_check) + if args.gdb or args.bt or args.gdb_cmd: + return run_gdb(cmd, args) + if args.dry_run: tout.notice(shlex.join(cmd)) return 0 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 a3f91fb..c52320c 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, @@ -147,6 +150,7 @@ def make_args(**kwargs): 'pollute': None, 'pytest': None, 'quiet': False, + 'remote': None, 'setup_only': False, 'show_cmd': False, 'show_output': False, @@ -864,7 +868,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 +878,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 +888,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 +918,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 +940,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 +979,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: @@ -998,6 +1002,75 @@ 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""" + # 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 = ('/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('') + + 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', '-o', self.build_dir]) + with mock.patch.object(command, 'run_one', mock_run): + 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' + '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', '-o', 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', '-o', 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""" @@ -2122,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']) @@ -2138,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): @@ -2923,6 +3032,104 @@ 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 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) + + 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): @@ -2950,6 +3157,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(): @@ -3462,6 +3671,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 @@ -5997,6 +6247,101 @@ 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) + + 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""" diff --git a/uman_pkg/settings.py b/uman_pkg/settings.py index 1a29f65..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 @@ -27,6 +34,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),