Skip to content

Commit 43494cd

Browse files
committed
feat: keep every provider's hosts grouped under one header
Provider group headers are now resolved through the registry everywhere, so all 16 providers behave the same. OVHcloud, Leaseweb, i3D.net and TransIP kept their group header on startup only by coincidence before; their display name was maintained in a separate list that had drifted. Group-comment repair derives its known-provider set from the registry too, so absorbed headers relocate for every provider. Sync decides whether to write a group header from the same top-level lookup it uses to place the host, so a newly synced host gets its header even when the provider's existing hosts live only in an Included file. Adds a per-provider scenario matrix and startup-cleanup coverage across all 16 providers.
1 parent 5d81bab commit 43494cd

8 files changed

Lines changed: 549 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## 3.21.1 - 2026-05-31
2+
3+
- feat: Your cloud hosts stay grouped under their provider.
4+
- feat: Every cloud provider keeps one clean group header in ~/.ssh/config and the purple host list, so your hosts read the same in the file and on screen.
5+
- feat: Newly synced and imported hosts always land inside their provider's group, so your config stays organized as it grows.
6+
- feat: Grouping holds whether your hosts live in your main config or an Included file.
7+
18
## 3.21.0 - 2026-05-31
29

310
- feat: Tidy SSH config with a blank line between hosts.

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "purple-ssh"
3-
version = "3.21.0"
3+
version = "3.21.1"
44
edition = "2024"
55
description = "Open-source terminal SSH manager that keeps ~/.ssh/config in sync with your cloud infra. Spin up a VM on AWS, GCP, Azure, Hetzner or 12 other cloud providers and it appears in your host list. Destroy it and the entry dims. Search hundreds of hosts, transfer files, manage Docker and Podman over SSH, sign Vault SSH certs. Rust TUI, MIT licensed."
66
license = "MIT"

src/providers/sync.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,18 @@ pub fn sync_provider(
108108
// Track which server IDs are still in the remote set (also deduplicates)
109109
let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
110110

111-
// Only add group header if this PROVIDER (any config) has no existing hosts.
112-
// Group headers are shared across labeled configs of the same provider so
113-
// both `[do:work]` and `[do:personal]` hosts live under one "DigitalOcean" header.
114-
let mut needs_header = !dry_run && config.find_hosts_by_provider(provider.name()).is_empty();
111+
// Add a group header when this provider has no TOP-LEVEL host yet. The
112+
// header and every synced host live at top level (Include files are
113+
// read-only), so this must agree with find_provider_insert_position, which
114+
// is also top-level only. Using the include-aware find_hosts_by_provider
115+
// here left a new host header-less when the provider's existing hosts lived
116+
// solely in an Include. Group headers are still shared across labeled
117+
// configs of one provider: the second labeled config finds the first
118+
// config's top-level host and skips a duplicate header.
119+
let mut needs_header = !dry_run
120+
&& config
121+
.find_provider_insert_position(provider.name())
122+
.is_none();
115123

116124
for remote in remote_hosts {
117125
if !remote_ids.insert(remote.server_id.clone()) {

src/providers/sync_tests.rs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5797,6 +5797,199 @@ fn test_sync_consecutive_provider_hosts_separated() {
57975797
assert!(!output.contains("\n\n\n"), "triple blank lines:\n{output}");
57985798
}
57995799

5800+
// --- Include-file and grouping consistency tests ---
5801+
5802+
#[test]
5803+
fn sync_includes_only_host_gets_top_level_group_header() {
5804+
// A provider whose only existing host lives in an Include file. A newly
5805+
// synced host must be written at top level WITH its group header, not
5806+
// appended bare at the end of the file. needs_header (include-aware) and
5807+
// find_provider_insert_position (top-level only) must agree.
5808+
use crate::ssh_config::model::{IncludeDirective, IncludedFile};
5809+
let included_file = IncludedFile {
5810+
path: PathBuf::from("/tmp/inc_grp_config"),
5811+
elements: SshConfigFile::parse_content(
5812+
"Host do-inc\n HostName 1.2.3.4\n # purple:provider digitalocean:inc1\n",
5813+
),
5814+
};
5815+
let mut config = SshConfigFile {
5816+
elements: vec![ConfigElement::Include(IncludeDirective {
5817+
raw_line: "Include inc_grp_config".to_string(),
5818+
pattern: "inc_grp_config".to_string(),
5819+
resolved_files: vec![included_file],
5820+
})],
5821+
path: test_config_path(),
5822+
crlf: false,
5823+
bom: false,
5824+
};
5825+
let section = make_section();
5826+
// Remote: the included host (dedup, no add) plus a brand-new one.
5827+
let remote = vec![
5828+
ProviderHost::new("inc1".into(), "inc".into(), "1.2.3.4".into(), Vec::new()),
5829+
ProviderHost::new("999".into(), "fresh".into(), "9.9.9.9".into(), Vec::new()),
5830+
];
5831+
let result = sync_provider(
5832+
&mut config,
5833+
&MockProvider,
5834+
&remote,
5835+
&section,
5836+
false,
5837+
false,
5838+
false,
5839+
);
5840+
assert_eq!(
5841+
result.added, 1,
5842+
"only the new host is added (included host deduped)"
5843+
);
5844+
5845+
let output = config.serialize();
5846+
// The new top-level host must be present.
5847+
assert!(
5848+
output.contains("Host do-fresh"),
5849+
"new host missing:\n{output}"
5850+
);
5851+
// It must carry a DigitalOcean group header at top level.
5852+
let has_top_level_header = config.elements.iter().any(
5853+
|e| matches!(e, ConfigElement::GlobalLine(l) if l.trim() == "# purple:group DigitalOcean"),
5854+
);
5855+
assert!(
5856+
has_top_level_header,
5857+
"new top-level synced host must get a DigitalOcean group header:\n{output}"
5858+
);
5859+
}
5860+
5861+
#[test]
5862+
fn sync_labeled_configs_share_one_group_header() {
5863+
// Two labeled configs of the same provider (do:work, do:personal) must
5864+
// share exactly ONE group header (the documented design), with both hosts
5865+
// grouped under it. Locks in the adversarial verdict that this is correct.
5866+
let mut config = empty_config();
5867+
let work = ProviderSection {
5868+
id: crate::providers::config::ProviderConfigId::labeled("digitalocean", "work"),
5869+
alias_prefix: "do-work".to_string(),
5870+
..make_section()
5871+
};
5872+
let personal = ProviderSection {
5873+
id: crate::providers::config::ProviderConfigId::labeled("digitalocean", "personal"),
5874+
alias_prefix: "do-personal".to_string(),
5875+
..make_section()
5876+
};
5877+
sync_provider(
5878+
&mut config,
5879+
&MockProvider,
5880+
&[ProviderHost::new(
5881+
"1".into(),
5882+
"a".into(),
5883+
"1.1.1.1".into(),
5884+
Vec::new(),
5885+
)],
5886+
&work,
5887+
false,
5888+
false,
5889+
false,
5890+
);
5891+
sync_provider(
5892+
&mut config,
5893+
&MockProvider,
5894+
&[ProviderHost::new(
5895+
"2".into(),
5896+
"b".into(),
5897+
"2.2.2.2".into(),
5898+
Vec::new(),
5899+
)],
5900+
&personal,
5901+
false,
5902+
false,
5903+
false,
5904+
);
5905+
5906+
let header_count = config
5907+
.elements
5908+
.iter()
5909+
.filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.trim() == "# purple:group DigitalOcean"))
5910+
.count();
5911+
assert_eq!(
5912+
header_count,
5913+
1,
5914+
"labeled configs of one provider share exactly one header:\n{}",
5915+
config.serialize()
5916+
);
5917+
// Both hosts present and after the single header.
5918+
let header_pos = config
5919+
.elements
5920+
.iter()
5921+
.position(|e| matches!(e, ConfigElement::GlobalLine(l) if l.trim() == "# purple:group DigitalOcean"))
5922+
.expect("header present");
5923+
let work_pos = config
5924+
.elements
5925+
.iter()
5926+
.position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == "do-work-a"))
5927+
.expect("work host present");
5928+
let personal_pos = config
5929+
.elements
5930+
.iter()
5931+
.position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == "do-personal-b"))
5932+
.expect("personal host present");
5933+
assert!(work_pos > header_pos && personal_pos > header_pos);
5934+
}
5935+
5936+
#[test]
5937+
fn sync_orphan_top_level_header_plus_include_no_duplicate() {
5938+
// Edge case for the needs_header fix: a top-level group header already
5939+
// exists (no top-level host under it yet) AND the provider has a host in an
5940+
// Include. Syncing a new host must not pile up a second top-level header;
5941+
// serialize collapses and at most one header should remain meaningful.
5942+
use crate::ssh_config::model::{IncludeDirective, IncludedFile};
5943+
let included_file = IncludedFile {
5944+
path: PathBuf::from("/tmp/inc_orphan_config"),
5945+
elements: SshConfigFile::parse_content(
5946+
"Host do-inc\n HostName 1.2.3.4\n # purple:provider digitalocean:inc1\n",
5947+
),
5948+
};
5949+
let mut config = SshConfigFile {
5950+
elements: vec![
5951+
ConfigElement::GlobalLine("# purple:group DigitalOcean".to_string()),
5952+
ConfigElement::GlobalLine(String::new()),
5953+
ConfigElement::Include(IncludeDirective {
5954+
raw_line: "Include inc_orphan_config".to_string(),
5955+
pattern: "inc_orphan_config".to_string(),
5956+
resolved_files: vec![included_file],
5957+
}),
5958+
],
5959+
path: test_config_path(),
5960+
crlf: false,
5961+
bom: false,
5962+
};
5963+
let section = make_section();
5964+
let remote = vec![
5965+
ProviderHost::new("inc1".into(), "inc".into(), "1.2.3.4".into(), Vec::new()),
5966+
ProviderHost::new("999".into(), "fresh".into(), "9.9.9.9".into(), Vec::new()),
5967+
];
5968+
sync_provider(
5969+
&mut config,
5970+
&MockProvider,
5971+
&remote,
5972+
&section,
5973+
false,
5974+
false,
5975+
false,
5976+
);
5977+
5978+
let output = config.serialize();
5979+
// The new host is present.
5980+
assert!(
5981+
output.contains("Host do-fresh"),
5982+
"new host missing:\n{output}"
5983+
);
5984+
// No more than two headers, and serialize must not stack blank-line noise.
5985+
let header_count = output.matches("# purple:group DigitalOcean").count();
5986+
assert!(
5987+
header_count <= 2,
5988+
"excessive duplicate headers ({header_count}):\n{output}"
5989+
);
5990+
assert!(!output.contains("\n\n\n"), "triple blank lines:\n{output}");
5991+
}
5992+
58005993
// --- Multi-config provider tests ---
58015994

58025995
#[test]

src/ssh_config/repair.rs

Lines changed: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,13 @@
1212
1313
use super::model::{ConfigElement, SshConfigFile};
1414

15-
/// Display name for a provider used in `# purple:group` headers.
16-
/// Mirrors `providers::provider_display_name()` without a cross-module
17-
/// dependency.
15+
/// Display name for a provider used in `# purple:group` headers. Delegates to
16+
/// the provider registry so the cleanup matcher and the sync writer can never
17+
/// disagree. A previous hand-maintained copy here drifted: ovh, i3d, leaseweb
18+
/// and transip were missing, so their headers were deleted on startup despite
19+
/// active hosts.
1820
pub(super) fn provider_group_display_name(name: &str) -> &str {
19-
match name {
20-
"digitalocean" => "DigitalOcean",
21-
"vultr" => "Vultr",
22-
"linode" => "Linode",
23-
"hetzner" => "Hetzner",
24-
"upcloud" => "UpCloud",
25-
"proxmox" => "Proxmox VE",
26-
"aws" => "AWS EC2",
27-
"scaleway" => "Scaleway",
28-
"gcp" => "GCP",
29-
"azure" => "Azure",
30-
"tailscale" => "Tailscale",
31-
"oracle" => "Oracle Cloud",
32-
other => other,
33-
}
21+
crate::providers::provider_display_name(name)
3422
}
3523

3624
impl SshConfigFile {
@@ -77,26 +65,14 @@ impl SshConfigFile {
7765
/// group header. This protects hand-written comments from being silently
7866
/// scrubbed by the next-startup `remove_all_orphaned_group_headers` pass.
7967
pub fn repair_absorbed_group_comments(&mut self) -> usize {
80-
// Build the set of known provider display names once. The list is
81-
// closed under purple's supported providers and tiny, so allocation
82-
// cost is negligible.
83-
let known_providers: std::collections::HashSet<&str> = [
84-
"DigitalOcean",
85-
"Vultr",
86-
"Linode",
87-
"Hetzner",
88-
"UpCloud",
89-
"Proxmox VE",
90-
"AWS EC2",
91-
"Scaleway",
92-
"GCP",
93-
"Azure",
94-
"Tailscale",
95-
"Oracle Cloud",
96-
]
97-
.iter()
98-
.copied()
99-
.collect();
68+
// Derive known provider display names from the registry so this matcher
69+
// can never drift from the sync writer. A hand-maintained copy here
70+
// previously omitted ovh, leaseweb, i3d and transip, so their absorbed
71+
// group comments were never relocated to top-level GlobalLines.
72+
let known_providers: std::collections::HashSet<&str> = crate::providers::PROVIDER_NAMES
73+
.iter()
74+
.map(|n| crate::providers::provider_display_name(n))
75+
.collect();
10076

10177
let is_known_group = |raw: &str| -> bool {
10278
raw.trim()

0 commit comments

Comments
 (0)