Skip to content

Commit 64c502f

Browse files
committed
test: reproduce Starship prompt going static in bash sessions
The bash bootstrap Programa injects as PROMPT_COMMAND begins with unset PROMPT_COMMAND. When a user's startup files run eval "$(starship init bash)" before the first prompt, that appends starship_precmd to PROMPT_COMMAND; the bootstrap's unset then throws it away after running once, so the Starship prompt stops updating after the first command. Extract the bootstrap into its own file (Resources/shell-integration/programa-bash-bootstrap.bash) so the app and the test share one source of truth, and add a regression test that drives the real bootstrap plus a Starship-shaped prompt hook through bash. This keeps the current buggy behavior so CI shows the new test red; the fix follows in the next commit.
1 parent 332af61 commit 64c502f

3 files changed

Lines changed: 216 additions & 15 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Programa bash prompt bootstrap.
2+
#
3+
# macOS ships /bin/bash 3.2, where Ghostty's automatic bash integration is
4+
# unsupported and HOME-based wrapper startup is not reliable. Programa instead
5+
# exports this script as PROMPT_COMMAND so it runs once on the first
6+
# interactive prompt: it sources Programa's bash integration and then hands
7+
# control to _programa_prompt_command.
8+
#
9+
# This file is the single source of truth. Sources/GhosttyTerminalView.swift
10+
# reads it (stripping these comments) and exports it as PROMPT_COMMAND, and
11+
# programaTests/GhosttyConfigTests.swift exercises it.
12+
unset PROMPT_COMMAND
13+
if [[ "${PROGRAMA_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then
14+
_programa_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"
15+
[[ -r "$_programa_ghostty_bash" ]] && source "$_programa_ghostty_bash"
16+
fi
17+
if [[ "${PROGRAMA_SHELL_INTEGRATION:-1}" != "0" && -n "${PROGRAMA_SHELL_INTEGRATION_DIR:-}" ]]; then
18+
_programa_bash_integration="$PROGRAMA_SHELL_INTEGRATION_DIR/programa-bash-integration.bash"
19+
[[ -r "$_programa_bash_integration" ]] && source "$_programa_bash_integration"
20+
fi
21+
unset _programa_ghostty_bash _programa_bash_integration
22+
if declare -F _programa_prompt_command >/dev/null 2>&1; then _programa_prompt_command; fi

Sources/GhosttyTerminalView.swift

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4069,21 +4069,27 @@ final class TerminalSurface: Identifiable, ObservableObject {
40694069
}
40704070
// macOS ships /bin/bash 3.2, where Ghostty's automatic bash
40714071
// integration is unsupported and HOME-based wrapper startup is
4072-
// not reliable. Bootstrap cmux bash integration on the first
4073-
// interactive prompt instead.
4074-
setManagedEnvironmentValue("PROMPT_COMMAND", """
4075-
unset PROMPT_COMMAND; \
4076-
if [[ "${PROGRAMA_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then \
4077-
_programa_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"; \
4078-
[[ -r "$_programa_ghostty_bash" ]] && source "$_programa_ghostty_bash"; \
4079-
fi; \
4080-
if [[ "${PROGRAMA_SHELL_INTEGRATION:-1}" != "0" && -n "${PROGRAMA_SHELL_INTEGRATION_DIR:-}" ]]; then \
4081-
_programa_bash_integration="$PROGRAMA_SHELL_INTEGRATION_DIR/programa-bash-integration.bash"; \
4082-
[[ -r "$_programa_bash_integration" ]] && source "$_programa_bash_integration"; \
4083-
fi; \
4084-
unset _programa_ghostty_bash _programa_bash_integration; \
4085-
if declare -F _programa_prompt_command >/dev/null 2>&1; then _programa_prompt_command; fi
4086-
""")
4072+
// not reliable. Bootstrap Programa bash integration on the
4073+
// first interactive prompt by exporting the shared bootstrap
4074+
// script as PROMPT_COMMAND. The script lives in
4075+
// Resources/shell-integration so the app and the regression
4076+
// test share one source of truth. Doc comments and blank
4077+
// lines are stripped so users never see them in
4078+
// $PROMPT_COMMAND; the test mirrors this.
4079+
let bashBootstrapPath = (integrationDir as NSString)
4080+
.appendingPathComponent("programa-bash-bootstrap.bash")
4081+
if let rawBootstrap = try? String(contentsOfFile: bashBootstrapPath, encoding: .utf8) {
4082+
let bootstrap = rawBootstrap
4083+
.components(separatedBy: "\n")
4084+
.filter { line in
4085+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
4086+
return !trimmed.isEmpty && !trimmed.hasPrefix("#")
4087+
}
4088+
.joined(separator: "\n")
4089+
if !bootstrap.isEmpty {
4090+
setManagedEnvironmentValue("PROMPT_COMMAND", bootstrap)
4091+
}
4092+
}
40874093
}
40884094
}
40894095
env = Self.mergedStartupEnvironment(

programaTests/GhosttyConfigTests.swift

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3345,6 +3345,179 @@ 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\nstdout:\n\(fields["__stdout__"] ?? "")\nstderr:\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+
}
3378+
3379+
let ps1Values = (1...3).map { fields["PS1_\($0)"] ?? "" }
3380+
XCTAssertNotEqual(
3381+
ps1Values[0], ps1Values[1],
3382+
"starship prompt went static across prompts (it stopped re-rendering): \(ps1Values)" + debug
3383+
)
3384+
XCTAssertNotEqual(
3385+
ps1Values[1], ps1Values[2],
3386+
"starship prompt went static across prompts (it stopped re-rendering): \(ps1Values)" + debug
3387+
)
3388+
XCTAssertTrue(
3389+
ps1Values[2].contains("n=3"),
3390+
"starship_precmd did not run on every prompt; final PS1=<\(ps1Values[2])>" + debug
3391+
)
3392+
XCTAssertTrue(
3393+
ps1Values[2].contains("cwd=cd-target"),
3394+
"prompt did not pick up the new cwd after cd; final PS1=<\(ps1Values[2])>" + debug
3395+
)
3396+
}
3397+
3398+
/// Drives `Resources/shell-integration/programa-bash-bootstrap.bash`
3399+
/// through real bash, modeling bash's "evaluate PROMPT_COMMAND before
3400+
/// each prompt" loop directly (deterministic, no PTY needed). When
3401+
/// `withUserPromptHook` is true, simulates a user's startup files running
3402+
/// `eval "$(starship init bash)"` and appending `starship_precmd` to
3403+
/// PROMPT_COMMAND before the first prompt, exactly as the real Starship
3404+
/// binary's bash init does for non-bash-preexec shells.
3405+
private func runBashBootstrapDriver(withUserPromptHook: Bool) throws -> [String: String] {
3406+
let fileManager = FileManager.default
3407+
let repoRoot = URL(fileURLWithPath: #filePath)
3408+
.deletingLastPathComponent()
3409+
.deletingLastPathComponent()
3410+
let bootstrapSourcePath = repoRoot
3411+
.appendingPathComponent("Resources/shell-integration/programa-bash-bootstrap.bash")
3412+
let rawBootstrap = try String(contentsOf: bootstrapSourcePath, encoding: .utf8)
3413+
// Mirrors Sources/GhosttyTerminalView.swift's comment/blank-line
3414+
// stripping so the test exercises exactly what ships as
3415+
// $PROMPT_COMMAND.
3416+
let leanBootstrap = rawBootstrap
3417+
.components(separatedBy: "\n")
3418+
.filter { line in
3419+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
3420+
return !trimmed.isEmpty && !trimmed.hasPrefix("#")
3421+
}
3422+
.joined(separator: "\n")
3423+
3424+
let root = fileManager.temporaryDirectory
3425+
.appendingPathComponent("cmux-bash-bootstrap-starship-\(UUID().uuidString)")
3426+
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
3427+
defer { try? fileManager.removeItem(at: root) }
3428+
3429+
let bootstrapFile = root.appendingPathComponent("bootstrap.bash")
3430+
try leanBootstrap.write(to: bootstrapFile, atomically: true, encoding: .utf8)
3431+
3432+
let cdTarget = root.appendingPathComponent("cd-target")
3433+
try fileManager.createDirectory(at: cdTarget, withIntermediateDirectories: true)
3434+
3435+
let shellIntegrationDir = repoRoot.appendingPathComponent("Resources/shell-integration")
3436+
3437+
let driver = #"""
3438+
set +e
3439+
PROMPT_COMMAND="$(cat "$BOOTSTRAP_FILE")"
3440+
3441+
if [[ "${WITH_USER_HOOK:-1}" == "1" ]]; then
3442+
starship_precmd() {
3443+
STARSHIP_COUNTER=$((STARSHIP_COUNTER + 1))
3444+
PS1="cwd=${PWD##*/} n=${STARSHIP_COUNTER} "
3445+
}
3446+
if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
3447+
PROMPT_COMMAND+=(starship_precmd)
3448+
else
3449+
PROMPT_COMMAND=${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}"starship_precmd"
3450+
fi
3451+
fi
3452+
3453+
_emit() { printf '%s<%s>\n' "$1" "${2//$'\n'/<NL>}"; }
3454+
_emit PC_AFTER_RC "$PROMPT_COMMAND"
3455+
3456+
_render() {
3457+
if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
3458+
local pc
3459+
for pc in "${PROMPT_COMMAND[@]}"; do eval "$pc"; done
3460+
else
3461+
eval "$PROMPT_COMMAND"
3462+
fi
3463+
}
3464+
3465+
STARSHIP_COUNTER=0
3466+
i=0
3467+
while (( i < 3 )); do
3468+
i=$((i + 1))
3469+
(( i == 3 )) && cd "$CD_TARGET"
3470+
_render
3471+
_emit "PS1_$i" "$PS1"
3472+
_emit "PC_$i" "$PROMPT_COMMAND"
3473+
done
3474+
"""#
3475+
3476+
let process = Process()
3477+
process.executableURL = URL(fileURLWithPath: "/bin/bash")
3478+
process.arguments = ["--noprofile", "--norc", "-c", driver]
3479+
process.environment = [
3480+
"HOME": root.path,
3481+
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
3482+
"BOOTSTRAP_FILE": bootstrapFile.path,
3483+
"CD_TARGET": cdTarget.path,
3484+
"WITH_USER_HOOK": withUserPromptHook ? "1" : "0",
3485+
"PROGRAMA_LOAD_GHOSTTY_BASH_INTEGRATION": "0",
3486+
"GHOSTTY_RESOURCES_DIR": "",
3487+
"PROGRAMA_SHELL_INTEGRATION": "1",
3488+
"PROGRAMA_SHELL_INTEGRATION_DIR": shellIntegrationDir.path,
3489+
]
3490+
3491+
let stdout = Pipe()
3492+
let stderr = Pipe()
3493+
process.standardOutput = stdout
3494+
process.standardError = stderr
3495+
3496+
try process.run()
3497+
let deadline = Date().addingTimeInterval(5)
3498+
while process.isRunning && Date() < deadline {
3499+
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
3500+
}
3501+
if process.isRunning {
3502+
process.terminate()
3503+
process.waitUntilExit()
3504+
XCTFail("Timed out waiting for bash bootstrap driver to exit")
3505+
}
3506+
3507+
let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
3508+
let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
3509+
XCTAssertEqual(process.terminationStatus, 0, "stdout:\n\(output)\nstderr:\n\(error)")
3510+
3511+
var fields: [String: String] = ["__stdout__": output, "__stderr__": error]
3512+
for line in output.split(separator: "\n", omittingEmptySubsequences: true) {
3513+
guard let openIdx = line.firstIndex(of: "<"), line.hasSuffix(">") else { continue }
3514+
let key = String(line[line.startIndex..<openIdx])
3515+
let value = String(line[line.index(after: openIdx)..<line.index(before: line.endIndex)])
3516+
fields[key] = value
3517+
}
3518+
return fields
3519+
}
3520+
33483521
private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String {
33493522
try runInteractiveZsh(
33503523
cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration,

0 commit comments

Comments
 (0)