Skip to content

Commit 75e16c2

Browse files
authored
Merge pull request #124 from darkroomengineering/port/bash-starship-fix
fix: bash prompt goes static with Starship
2 parents c210d3a + 51ba5a1 commit 75e16c2

3 files changed

Lines changed: 265 additions & 15 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
# COMPOSE, don't clobber. A user's startup files may have appended their own
10+
# command to PROMPT_COMMAND *after* this bootstrap before the first prompt --
11+
# most notably `eval "$(starship init bash)"`, which appends starship_precmd.
12+
# We must remove only this bootstrap and keep whatever the user appended, so
13+
# hooks like starship_precmd keep running on every prompt instead of being
14+
# wiped after the first one.
15+
#
16+
# How: strip everything up to and including the marker at the end of this
17+
# script (the marker is the last occurrence, so the greedy ## match lands on
18+
# it), which leaves exactly the user's appended tail. Then trim the leading
19+
# separator and let programa-bash-integration.bash's PROMPT_COMMAND merge
20+
# prepend _cmux_prompt_command.
21+
#
22+
# This file is the single source of truth. Sources/GhosttyTerminalView.swift
23+
# reads it (stripping these comments) and exports it as PROMPT_COMMAND, and
24+
# programaTests/GhosttyConfigTests.swift exercises it.
25+
PROMPT_COMMAND="${PROMPT_COMMAND##*__programa_bash_bootstrap_marker__}"
26+
while [[ "$PROMPT_COMMAND" == [[:space:]\;]* ]]; do PROMPT_COMMAND="${PROMPT_COMMAND#?}"; done
27+
if [[ "${PROGRAMA_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then
28+
_programa_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"
29+
[[ -r "$_programa_ghostty_bash" ]] && source "$_programa_ghostty_bash"
30+
fi
31+
if [[ "${PROGRAMA_SHELL_INTEGRATION:-1}" != "0" && -n "${PROGRAMA_SHELL_INTEGRATION_DIR:-}" ]]; then
32+
_programa_bash_integration="$PROGRAMA_SHELL_INTEGRATION_DIR/programa-bash-integration.bash"
33+
[[ -r "$_programa_bash_integration" ]] && source "$_programa_bash_integration"
34+
fi
35+
unset _programa_ghostty_bash _programa_bash_integration
36+
if declare -F _programa_prompt_command >/dev/null 2>&1; then _programa_prompt_command; fi
37+
: __programa_bash_bootstrap_marker__

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: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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\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+
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\nstdout:\n\(fields["__stdout__"] ?? "")\nstderr:\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)\nstderr:\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

Comments
 (0)