@@ -3345,6 +3345,213 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
33453345 )
33463346 }
33473347
3348+ func testBashBootstrapPreservesStarshipPromptCommandAcrossPrompts( ) throws {
3349+ // Regression test: Programa's local macOS bash bootstrap is injected
3350+ // as PROMPT_COMMAND and used to begin by unconditionally clobbering
3351+ // PROMPT_COMMAND. A user's startup files run
3352+ // `eval "$(starship init bash)"` before the first prompt, which
3353+ // appends starship_precmd to PROMPT_COMMAND; the bootstrap then
3354+ // discarded it after running once, freezing the prompt. This drives
3355+ // the real bootstrap file (Resources/shell-integration/
3356+ // programa-bash-bootstrap.bash) plus a faithful starship stub
3357+ // through bash and asserts starship_precmd survives across prompts,
3358+ // rather than relying on a PTY.
3359+ let fields = try runBashBootstrapDriver ( withUserPromptHook: true )
3360+ let debug = " \n \n stdout: \n \( fields [ " __stdout__ " ] ?? " " ) \n stderr: \n \( fields [ " __stderr__ " ] ?? " " ) "
3361+
3362+ XCTAssertTrue (
3363+ ( fields [ " PC_AFTER_RC " ] ?? " " ) . contains ( " starship_precmd " ) ,
3364+ " test setup did not append starship_precmd to PROMPT_COMMAND " + debug
3365+ )
3366+
3367+ for i in 1 ... 3 {
3368+ let pc = fields [ " PC_ \( i) " ] ?? " "
3369+ XCTAssertTrue (
3370+ pc. contains ( " starship_precmd " ) ,
3371+ " starship_precmd was dropped from PROMPT_COMMAND at prompt \( i) ; the bootstrap took exclusive ownership instead of composing with the user's hook. PROMPT_COMMAND=< \( pc) > " + debug
3372+ )
3373+ XCTAssertTrue (
3374+ pc. contains ( " _cmux_prompt_command " ) ,
3375+ " Programa's own prompt hook is missing from PROMPT_COMMAND at prompt \( i) : < \( pc) > " + debug
3376+ )
3377+ XCTAssertFalse (
3378+ pc. contains ( " __programa_bash_bootstrap_marker__ " ) ,
3379+ " bootstrap marker leaked into PROMPT_COMMAND at prompt \( i) : < \( pc) > " + debug
3380+ )
3381+ }
3382+
3383+ let ps1Values = ( 1 ... 3 ) . map { fields [ " PS1_ \( $0) " ] ?? " " }
3384+ XCTAssertNotEqual (
3385+ ps1Values [ 0 ] , ps1Values [ 1 ] ,
3386+ " starship prompt went static across prompts (it stopped re-rendering): \( ps1Values) " + debug
3387+ )
3388+ XCTAssertNotEqual (
3389+ ps1Values [ 1 ] , ps1Values [ 2 ] ,
3390+ " starship prompt went static across prompts (it stopped re-rendering): \( ps1Values) " + debug
3391+ )
3392+ XCTAssertTrue (
3393+ ps1Values [ 2 ] . contains ( " n=3 " ) ,
3394+ " starship_precmd did not run on every prompt; final PS1=< \( ps1Values [ 2 ] ) > " + debug
3395+ )
3396+ XCTAssertTrue (
3397+ ps1Values [ 2 ] . contains ( " cwd=cd-target " ) ,
3398+ " prompt did not pick up the new cwd after cd; final PS1=< \( ps1Values [ 2 ] ) > " + debug
3399+ )
3400+ }
3401+
3402+ func testBashBootstrapInstallsPromptCommandWithoutUserHook( ) throws {
3403+ // No-regression guard: with no user PROMPT_COMMAND hook (plain bash,
3404+ // no Starship or similar prompt framework), the bootstrap must still
3405+ // install Programa's own prompt hook -- and must not leak its
3406+ // internal marker into PROMPT_COMMAND.
3407+ let fields = try runBashBootstrapDriver ( withUserPromptHook: false )
3408+ let debug = " \n \n stdout: \n \( fields [ " __stdout__ " ] ?? " " ) \n stderr: \n \( fields [ " __stderr__ " ] ?? " " ) "
3409+
3410+ for i in 1 ... 3 {
3411+ let pc = fields [ " PC_ \( i) " ] ?? " "
3412+ // Assert the observable contract (hook installed, bootstrap
3413+ // marker removed, no phantom hook) rather than the exact
3414+ // integration-internal string, so this stays green if
3415+ // programa-bash-integration.bash ever tweaks its PROMPT_COMMAND
3416+ // merge.
3417+ XCTAssertTrue (
3418+ pc. contains ( " _cmux_prompt_command " ) ,
3419+ " plain-bash bootstrap did not install Programa's prompt hook at prompt \( i) : < \( pc) > " + debug
3420+ )
3421+ XCTAssertFalse (
3422+ pc. contains ( " __programa_bash_bootstrap_marker__ " ) ,
3423+ " bootstrap marker leaked into PROMPT_COMMAND at prompt \( i) : < \( pc) > " + debug
3424+ )
3425+ XCTAssertFalse (
3426+ pc. contains ( " starship_precmd " ) ,
3427+ " unexpected starship hook in plain-bash PROMPT_COMMAND at prompt \( i) : < \( pc) > " + debug
3428+ )
3429+ }
3430+ }
3431+
3432+ /// Drives `Resources/shell-integration/programa-bash-bootstrap.bash`
3433+ /// through real bash, modeling bash's "evaluate PROMPT_COMMAND before
3434+ /// each prompt" loop directly (deterministic, no PTY needed). When
3435+ /// `withUserPromptHook` is true, simulates a user's startup files running
3436+ /// `eval "$(starship init bash)"` and appending `starship_precmd` to
3437+ /// PROMPT_COMMAND before the first prompt, exactly as the real Starship
3438+ /// binary's bash init does for non-bash-preexec shells.
3439+ private func runBashBootstrapDriver( withUserPromptHook: Bool ) throws -> [ String : String ] {
3440+ let fileManager = FileManager . default
3441+ let repoRoot = URL ( fileURLWithPath: #filePath)
3442+ . deletingLastPathComponent ( )
3443+ . deletingLastPathComponent ( )
3444+ let bootstrapSourcePath = repoRoot
3445+ . appendingPathComponent ( " Resources/shell-integration/programa-bash-bootstrap.bash " )
3446+ let rawBootstrap = try String ( contentsOf: bootstrapSourcePath, encoding: . utf8)
3447+ // Mirrors Sources/GhosttyTerminalView.swift's comment/blank-line
3448+ // stripping so the test exercises exactly what ships as
3449+ // $PROMPT_COMMAND.
3450+ let leanBootstrap = rawBootstrap
3451+ . components ( separatedBy: " \n " )
3452+ . filter { line in
3453+ let trimmed = line. trimmingCharacters ( in: . whitespacesAndNewlines)
3454+ return !trimmed. isEmpty && !trimmed. hasPrefix ( " # " )
3455+ }
3456+ . joined ( separator: " \n " )
3457+
3458+ let root = fileManager. temporaryDirectory
3459+ . appendingPathComponent ( " cmux-bash-bootstrap-starship- \( UUID ( ) . uuidString) " )
3460+ try fileManager. createDirectory ( at: root, withIntermediateDirectories: true )
3461+ defer { try ? fileManager. removeItem ( at: root) }
3462+
3463+ let bootstrapFile = root. appendingPathComponent ( " bootstrap.bash " )
3464+ try leanBootstrap. write ( to: bootstrapFile, atomically: true , encoding: . utf8)
3465+
3466+ let cdTarget = root. appendingPathComponent ( " cd-target " )
3467+ try fileManager. createDirectory ( at: cdTarget, withIntermediateDirectories: true )
3468+
3469+ let shellIntegrationDir = repoRoot. appendingPathComponent ( " Resources/shell-integration " )
3470+
3471+ let driver = #"""
3472+ set +e
3473+ PROMPT_COMMAND="$(cat "$BOOTSTRAP_FILE")"
3474+
3475+ if [[ "${WITH_USER_HOOK:-1}" == "1" ]]; then
3476+ starship_precmd() {
3477+ STARSHIP_COUNTER=$((STARSHIP_COUNTER + 1))
3478+ PS1="cwd=${PWD##*/} n=${STARSHIP_COUNTER} "
3479+ }
3480+ if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
3481+ PROMPT_COMMAND+=(starship_precmd)
3482+ else
3483+ PROMPT_COMMAND=${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}"starship_precmd"
3484+ fi
3485+ fi
3486+
3487+ _emit() { printf '%s<%s>\n' "$1" "${2//$'\n'/<NL>}"; }
3488+ _emit PC_AFTER_RC "$PROMPT_COMMAND"
3489+
3490+ _render() {
3491+ if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
3492+ local pc
3493+ for pc in "${PROMPT_COMMAND[@]}"; do eval "$pc"; done
3494+ else
3495+ eval "$PROMPT_COMMAND"
3496+ fi
3497+ }
3498+
3499+ STARSHIP_COUNTER=0
3500+ i=0
3501+ while (( i < 3 )); do
3502+ i=$((i + 1))
3503+ (( i == 3 )) && cd "$CD_TARGET"
3504+ _render
3505+ _emit "PS1_$i" "$PS1"
3506+ _emit "PC_$i" "$PROMPT_COMMAND"
3507+ done
3508+ """#
3509+
3510+ let process = Process ( )
3511+ process. executableURL = URL ( fileURLWithPath: " /bin/bash " )
3512+ process. arguments = [ " --noprofile " , " --norc " , " -c " , driver]
3513+ process. environment = [
3514+ " HOME " : root. path,
3515+ " PATH " : " /usr/bin:/bin:/usr/sbin:/sbin " ,
3516+ " BOOTSTRAP_FILE " : bootstrapFile. path,
3517+ " CD_TARGET " : cdTarget. path,
3518+ " WITH_USER_HOOK " : withUserPromptHook ? " 1 " : " 0 " ,
3519+ " PROGRAMA_LOAD_GHOSTTY_BASH_INTEGRATION " : " 0 " ,
3520+ " GHOSTTY_RESOURCES_DIR " : " " ,
3521+ " PROGRAMA_SHELL_INTEGRATION " : " 1 " ,
3522+ " PROGRAMA_SHELL_INTEGRATION_DIR " : shellIntegrationDir. path,
3523+ ]
3524+
3525+ let stdout = Pipe ( )
3526+ let stderr = Pipe ( )
3527+ process. standardOutput = stdout
3528+ process. standardError = stderr
3529+
3530+ try process. run ( )
3531+ let deadline = Date ( ) . addingTimeInterval ( 5 )
3532+ while process. isRunning && Date ( ) < deadline {
3533+ _ = RunLoop . current. run ( mode: . default, before: Date ( ) . addingTimeInterval ( 0.01 ) )
3534+ }
3535+ if process. isRunning {
3536+ process. terminate ( )
3537+ process. waitUntilExit ( )
3538+ XCTFail ( " Timed out waiting for bash bootstrap driver to exit " )
3539+ }
3540+
3541+ let output = String ( data: stdout. fileHandleForReading. readDataToEndOfFile ( ) , encoding: . utf8) ?? " "
3542+ let error = String ( data: stderr. fileHandleForReading. readDataToEndOfFile ( ) , encoding: . utf8) ?? " "
3543+ XCTAssertEqual ( process. terminationStatus, 0 , " stdout: \n \( output) \n stderr: \n \( error) " )
3544+
3545+ var fields : [ String : String ] = [ " __stdout__ " : output, " __stderr__ " : error]
3546+ for line in output. split ( separator: " \n " , omittingEmptySubsequences: true ) {
3547+ guard let openIdx = line. firstIndex ( of: " < " ) , line. hasSuffix ( " > " ) else { continue }
3548+ let key = String ( line [ line. startIndex..< openIdx] )
3549+ let value = String ( line [ line. index ( after: openIdx) ..< line. index ( before: line. endIndex) ] )
3550+ fields [ key] = value
3551+ }
3552+ return fields
3553+ }
3554+
33483555 private func runInteractiveZsh( cmuxLoadGhosttyIntegration: Bool ) throws -> String {
33493556 try runInteractiveZsh (
33503557 cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration,
0 commit comments