Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/test-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,23 @@ jobs:
;;
esac

# Start from main's git objects, so checkout fetches only what changed
# since then instead of the whole tree and its submodules
# (scripts/ci/git-seed.sh, as ci-macos.yml does). The script is read at
# this commit over HTTPS because nothing is checked out yet. A miss, or a
# reused self-hosted workspace, leaves checkout to work as before.
- name: Restore git object seed
continue-on-error: true
timeout-minutes: 3
env:
CI_CACHE_R2_PUBLIC_URL: ${{ vars.CI_CACHE_R2_PUBLIC_URL || 'https://ci-cache.cmux.com' }}
run: |
set -euo pipefail
script="$RUNNER_TEMP/git-seed.sh"
curl --fail --silent --show-error --location --connect-timeout 10 --max-time 30 \
-o "$script" "https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$GITHUB_SHA/scripts/ci/git-seed.sh"
bash "$script" restore "$GITHUB_WORKSPACE"

- name: Clear stale git locks (self-hosted reused workspace)
shell: bash
run: |
Expand All @@ -419,6 +436,21 @@ jobs:
fi

- name: Checkout
id: checkout
continue-on-error: true
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
submodules: recursive

# A seeded repository that checkout cannot use (a submodule moved to
# another URL) must not fail the retry the same way.
- name: Discard the git object seed after a failed checkout
if: steps.checkout.outcome == 'failure'
run: rm -rf "$GITHUB_WORKSPACE/.git"

- name: Retry checkout without the git object seed
if: steps.checkout.outcome == 'failure'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
Expand Down Expand Up @@ -837,6 +869,23 @@ jobs:
;;
esac

# Start from main's git objects, so checkout fetches only what changed
# since then instead of the whole tree and its submodules
# (scripts/ci/git-seed.sh, as ci-macos.yml does). The script is read at
# this commit over HTTPS because nothing is checked out yet. A miss, or a
# reused self-hosted workspace, leaves checkout to work as before.
- name: Restore git object seed
continue-on-error: true
timeout-minutes: 3
env:
CI_CACHE_R2_PUBLIC_URL: ${{ vars.CI_CACHE_R2_PUBLIC_URL || 'https://ci-cache.cmux.com' }}
run: |
set -euo pipefail
script="$RUNNER_TEMP/git-seed.sh"
curl --fail --silent --show-error --location --connect-timeout 10 --max-time 30 \
-o "$script" "https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$GITHUB_SHA/scripts/ci/git-seed.sh"
bash "$script" restore "$GITHUB_WORKSPACE"

- name: Clear stale git locks (self-hosted reused workspace)
shell: bash
run: |
Expand All @@ -850,6 +899,21 @@ jobs:
fi

- name: Checkout
id: checkout
continue-on-error: true
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
submodules: recursive

# A seeded repository that checkout cannot use (a submodule moved to
# another URL) must not fail the retry the same way.
- name: Discard the git object seed after a failed checkout
if: steps.checkout.outcome == 'failure'
run: rm -rf "$GITHUB_WORKSPACE/.git"

- name: Retry checkout without the git object seed
if: steps.checkout.outcome == 'failure'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
Expand Down
86 changes: 86 additions & 0 deletions .github/workflows/test-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,49 @@ jobs:
*) echo "::error::Unsupported Swift package: $SELECTED_PACKAGE"; exit 1 ;;
esac

# Start from main's git objects, so checkout fetches only what changed
# since then instead of the whole tree and its submodules
# (scripts/ci/git-seed.sh, as ci-macos.yml does). The script is read at
# this commit over HTTPS because nothing is checked out yet. A miss, or a
# reused self-hosted workspace, leaves checkout to work as before.
- name: Restore git object seed
continue-on-error: true
timeout-minutes: 3
run: |
set -euo pipefail
script="$RUNNER_TEMP/git-seed.sh"
curl --fail --silent --show-error --location --connect-timeout 10 --max-time 30 \
-o "$script" "https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$GITHUB_SHA/scripts/ci/git-seed.sh"
bash "$script" restore "$GITHUB_WORKSPACE"

- name: Clear stale git locks (self-hosted reused workspace)
shell: bash
run: |
# Self-hosted macOS runners reuse the workspace. A job cancelled or
# killed mid-checkout can leave a stale .git/modules/*/index.lock that
# fails every later submodule checkout (e.g. ghostty). Clear them first.
ws="${GITHUB_WORKSPACE:-$PWD}"
rm -f "$ws/.git/index.lock" 2>/dev/null || true
if [ -d "$ws/.git/modules" ]; then
find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true
fi

- name: Checkout
id: checkout
continue-on-error: true
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.detect-ios-changes.outputs.target_sha }}
persist-credentials: false
submodules: false

# A seeded repository that checkout cannot use must not fail the retry the same way.
- name: Discard the git object seed after a failed checkout
if: steps.checkout.outcome == 'failure'
run: rm -rf "$GITHUB_WORKSPACE/.git"

- name: Retry checkout without the git object seed
if: steps.checkout.outcome == 'failure'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.detect-ios-changes.outputs.target_sha }}
Expand Down Expand Up @@ -577,7 +619,51 @@ jobs:
fi
;;
esac

# Start from main's git objects, so checkout fetches only what changed
# since then instead of the whole tree and its submodules
# (scripts/ci/git-seed.sh, as ci-macos.yml does). The script is read at
# this commit over HTTPS because nothing is checked out yet. A miss, or a
# reused self-hosted workspace, leaves checkout to work as before.
- name: Restore git object seed
continue-on-error: true
timeout-minutes: 3
run: |
set -euo pipefail
script="$RUNNER_TEMP/git-seed.sh"
curl --fail --silent --show-error --location --connect-timeout 10 --max-time 30 \
-o "$script" "https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$GITHUB_SHA/scripts/ci/git-seed.sh"
bash "$script" restore "$GITHUB_WORKSPACE"

- name: Clear stale git locks (self-hosted reused workspace)
shell: bash
run: |
# Self-hosted macOS runners reuse the workspace. A job cancelled or
# killed mid-checkout can leave a stale .git/modules/*/index.lock that
# fails every later submodule checkout (e.g. ghostty). Clear them first.
ws="${GITHUB_WORKSPACE:-$PWD}"
rm -f "$ws/.git/index.lock" 2>/dev/null || true
if [ -d "$ws/.git/modules" ]; then
find "$ws/.git/modules" -type f -name "*.lock" -delete 2>/dev/null || true
fi

- name: Checkout
id: checkout
continue-on-error: true
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.detect-ios-changes.outputs.target_sha }}
persist-credentials: false
submodules: recursive

# A seeded repository that checkout cannot use (a submodule moved to
# another URL) must not fail the retry the same way.
- name: Discard the git object seed after a failed checkout
if: steps.checkout.outcome == 'failure'
run: rm -rf "$GITHUB_WORKSPACE/.git"

- name: Retry checkout without the git object seed
if: steps.checkout.outcome == 'failure'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.detect-ios-changes.outputs.target_sha }}
Expand Down
20 changes: 5 additions & 15 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11191,27 +11191,17 @@ struct CMUXCLI {
}

case "set-color":
let (hexOpt, rem1) = parseOption(rest, name: "--hex")
// --color is an alias for --hex (mirrors the `custom_color`
// response field the RPC accepts under the `color` key).
// Always consume --color so it cannot be mistaken for the group id
// when both flags are passed; --hex wins.
let (colorOpt, rem0) = parseOption(rem1, name: "--color")
let (hexOpt, rem0) = parseOption(rest, name: "--hex")
params["group_id"] = try resolveGroupId(in: rem0)
// Treat --hex/--color with no value (or `""`) as a clear.
params["hex"] = hexOpt ?? colorOpt ?? ""
// Treat --hex with no value (or `--hex ""`) as a clear.
params["hex"] = hexOpt ?? ""
let resp = try client.sendV2(method: "workspace.group.set_color", params: params)
printWorkspaceGroupResponse(resp, jsonOutput: jsonOutput, idFormat: idFormat)

case "set-icon":
let (symbolOpt, rem1) = parseOption(rest, name: "--symbol")
// --icon is an alias for --symbol (mirrors the `icon_symbol`
// response field the RPC accepts under the `icon` key).
// Always consume --icon so it cannot be mistaken for the group id
// when both flags are passed; --symbol wins.
let (iconOpt, rem0) = parseOption(rem1, name: "--icon")
let (symbolOpt, rem0) = parseOption(rest, name: "--symbol")
params["group_id"] = try resolveGroupId(in: rem0)
params["symbol"] = symbolOpt ?? iconOpt ?? ""
params["symbol"] = symbolOpt ?? ""
let resp = try client.sendV2(method: "workspace.group.set_icon", params: params)
printWorkspaceGroupResponse(resp, jsonOutput: jsonOutput, idFormat: idFormat)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,46 +359,9 @@ extension ControlCommandCoordinator {
guard let gid = uuid(params, "group_id") else {
return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil)
}
let colorParam = Self.aliasStringParam(params, canonical: "hex", alias: "color")
// `custom_color` is the response field name, not an accepted input
// key: echoed back alone it would read as a set while actually
// clearing the override (#9594 class). Point the caller at the real
// keys instead.
if params["custom_color"] != nil, case .absent = colorParam {
return .err(
code: "invalid_params",
message: "unknown key custom_color; set_color accepts hex or color",
data: .object(["custom_color": params["custom_color"] ?? .null])
)
}
// `hex` is the canonical key and `color` its alias. Accept
// "hex"/"color": null to clear the override, or omit both entirely.
let normalized: String?
switch colorParam {
case .absent:
normalized = nil
case .supplied(let raw):
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
normalized = nil
} else if let canonical = Self.normalizeHexColor(trimmed) {
// Store the renderer's canonical spelling so a request cannot
// park a value the renderer would refuse to display.
normalized = canonical
} else {
return .err(
code: "invalid_params",
message: "color must be a 6-digit hex color like #FF3EA5 (leading # optional)",
data: .object([params["hex"] != nil ? "hex" : "color": .string(raw)])
)
}
case .typeMismatch(let value):
return .err(
code: "invalid_params",
message: "color must be a string holding a hex color",
data: .object([params["hex"] != nil ? "hex" : "color": value])
)
}
// Accept "hex": null to clear the override, or omit it entirely.
let hex: String? = rawString(params, "hex").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
let normalized: String? = (hex?.isEmpty == false) ? hex : nil
guard let ok = context?.controlSetWorkspaceGroupColor(
routing: routingSelectors(params), groupID: gid, hex: normalized
) else {
Expand All @@ -414,31 +377,8 @@ extension ControlCommandCoordinator {
guard let gid = uuid(params, "group_id") else {
return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil)
}
let symbolParam = Self.aliasStringParam(params, canonical: "symbol", alias: "icon")
// `icon_symbol` is the response field name, not an accepted input
// key: echoed back alone it would read as a set while actually
// clearing the symbol. Point the caller at the real keys instead.
if params["icon_symbol"] != nil, case .absent = symbolParam {
return .err(
code: "invalid_params",
message: "unknown key icon_symbol; set_icon accepts symbol or icon",
data: .object(["icon_symbol": params["icon_symbol"] ?? .null])
)
}
let normalized: String?
switch symbolParam {
case .absent:
normalized = nil
case .supplied(let raw):
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
normalized = trimmed.isEmpty ? nil : trimmed
case .typeMismatch(let value):
return .err(
code: "invalid_params",
message: "symbol must be a string",
data: .object([params["symbol"] != nil ? "symbol" : "icon": value])
)
}
let symbol: String? = rawString(params, "symbol").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
let normalized: String? = (symbol?.isEmpty == false) ? symbol : nil
guard let result = context?.controlSetWorkspaceGroupIcon(
routing: routingSelectors(params), groupID: gid, symbol: normalized
) else {
Expand Down Expand Up @@ -499,52 +439,6 @@ extension ControlCommandCoordinator {

// MARK: - Local helpers

/// How a string-typed RPC parameter (and its alias) was supplied.
enum AliasStringParam {
/// Neither key present, or the winning key is JSON `null` — the
/// documented spelling for "clear the override".
case absent
/// The winning key holds a JSON string (untrimmed).
case supplied(String)
/// The winning key holds some other JSON type. Surfacing this as
/// `invalid_params` keeps a mistyped value from silently clearing.
case typeMismatch(JSONValue)
}

/// Resolves `canonical` — falling back to `alias` — in `params`. The
/// canonical key wins whenever it is present, so an alias can never
/// override an explicit canonical `null` clear.
static func aliasStringParam(
_ params: [String: JSONValue],
canonical: String,
alias: String
) -> AliasStringParam {
for key in [canonical, alias] {
guard let value = params[key] else { continue }
if case .string(let string) = value {
return .supplied(string)
}
if case .null = value {
return .absent
}
return .typeMismatch(value)
}
return .absent
}

/// The canonical stored spelling for a group color: `#RRGGBB` (uppercase,
/// `#`-prefixed). This mirrors the renderer's own rule
/// (`WorkspaceTabColorSettings.normalizedHex`), which accepts a missing
/// leading `#` but only ever displays 6-digit values — so short and alpha
/// forms are rejected here rather than stored where they would silently
/// never render.
static func normalizeHexColor(_ value: String) -> String? {
let body = value.hasPrefix("#") ? String(value.dropFirst()) : value
// ASCII only: `Character.isHexDigit` also accepts fullwidth digits,
// which the renderer's `UInt64(_:radix:)` parse rejects.
guard body.count == 6, body.allSatisfy({ $0.isASCII && $0.isHexDigit }) else { return nil }
return "#" + body.uppercased()
}

/// The localized workspace-group error strings, resolved by the app
/// conformance against the app bundle.
Expand Down
Loading
Loading