diff --git a/CHANGELOG.md b/CHANGELOG.md index 0830ce6..1dc1e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Identities can carry per-directory signing and SSH settings. `create`/`edit` + gain `--format ` (`gpg.format`, for SSH commit signing on + git ≥ 2.34), `--ssh-key ` — shorthand for + `core.sshCommand = ssh -i -o IdentitiesOnly=yes`, so a per-identity key + is used and an agent key cannot shadow it — and `--ssh-command ` to store + a full `core.sshCommand` verbatim. `edit --no-format` / `--no-ssh` remove them. + Both new values are surfaced in `show`/`list --json` (`user.format`, + `user.ssh_command`) and are rejected if they carry control characters. +- `doctor` flags an identity whose `gpg.format` is not one of openpgp/ssh/x509, + and warns when `gpg.format = ssh` but the installed git is older than 2.34 + (which cannot sign with SSH). + ### Fixed - `doctor` no longer reports every routed directory as differing from its diff --git a/README.md b/README.md index 515691d..700116c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ under `${XDG_CONFIG_HOME:-~/.config}/git-id/`: path = "/Users/you/.config/git-id/identities/work.gitconfig" ~/.config/git-id/identities/work.gitconfig ← one fragment per identity -└─ [user] name / email / signingkey, [commit] gpgsign +└─ [user] name / email / signingkey, [gpg] format, + [commit] gpgsign, [core] sshCommand ``` A route on a directory applies to it **and every repository below it**. @@ -93,7 +94,7 @@ guessing one from your hostname. | Command | What it does | |---|---| | `git id init` | One-time setup (idempotent, backs up your gitconfig first). | -| `git id create ` | Create an identity (`--name`, `--email`, `--signing-key`, `--sign`; prompts interactively if omitted). | +| `git id create ` | Create an identity (`--name`, `--email`, `--signing-key`, `--sign`, `--format`, `--ssh-key`/`--ssh-command`; prompts for the basics if omitted). | | `git id list` | Identities and the directories routed to them (`--paths` for the dir → identity map). | | `git id show ` | Full detail of one identity, including the raw fragment. | | `git id edit ` | Update via flags, or open the fragment in `$EDITOR` when no flags are given. Hand-added keys survive. | @@ -107,6 +108,26 @@ guessing one from your hostname. Every command has a detailed `--help`. +### Signing and SSH keys + +An identity can carry its signing and transport settings, so they follow the +directory like the name and email do: + +```console +$ git id create work --name "Jane Doe" --email jane@work.example \ + --signing-key ABCDEF12 --sign --format ssh \ + --ssh-key ~/.ssh/id_work +``` + +- `--format ` sets `gpg.format` (SSH signing needs git ≥ 2.34). +- `--ssh-key ` is shorthand: git-id writes + `core.sshCommand = ssh -i -o IdentitiesOnly=yes`, so that exact key is + used and an agent key can't shadow it. Use `--ssh-command ""` instead to + store a full command verbatim (custom port, proxy, …). + +On `edit`, `--no-format` and `--no-ssh` remove those settings; `--signing-key ""` +removes the key. Anything you add to a fragment by hand is preserved. + ### Scripting `list`, `show` and `which` take `--json`: @@ -204,8 +225,7 @@ the binary and a real `git` inside its own temporary `HOME`. Notable changes are tracked in [CHANGELOG.md](CHANGELOG.md). -Future work: routing by remote URL (`hasconfig:remote.*.url`), per-identity -SSH key management, GPG/SSH signing helpers, `doctor --fix`. +Future work: routing by remote URL (`hasconfig:remote.*.url`), `doctor --fix`. ## Contributing diff --git a/src/cli.rs b/src/cli.rs index bf6b568..c0a3355 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -85,6 +85,17 @@ pub struct CreateArgs { /// Sign commits by default (sets commit.gpgsign=true) #[arg(long)] pub sign: bool, + /// Signing format (gpg.format): openpgp (default), ssh or x509 + #[arg(long, value_enum, value_name = "FORMAT")] + pub format: Option, + /// SSH key file for this identity's git operations; sets + /// `core.sshCommand = ssh -i -o IdentitiesOnly=yes` + #[arg(long, value_name = "PATH", conflicts_with = "ssh_command")] + pub ssh_key: Option, + /// Full ssh command for this identity's git operations, stored verbatim in + /// core.sshCommand (escape hatch for custom ports, proxies, …) + #[arg(long, value_name = "COMMAND", conflicts_with = "ssh_key")] + pub ssh_command: Option, /// Overwrite the identity if it already exists #[arg(long)] pub force: bool, @@ -128,6 +139,22 @@ pub struct EditArgs { /// Do not sign commits by default (sets commit.gpgsign=false) #[arg(long)] pub no_sign: bool, + /// New signing format (gpg.format): openpgp, ssh or x509 + #[arg(long, value_enum, value_name = "FORMAT", conflicts_with = "no_format")] + pub format: Option, + /// Remove the signing format (gpg.format), reverting to git's default + #[arg(long)] + pub no_format: bool, + /// SSH key file for git operations; sets + /// `core.sshCommand = ssh -i -o IdentitiesOnly=yes` + #[arg(long, value_name = "PATH", conflicts_with_all = ["ssh_command", "no_ssh"])] + pub ssh_key: Option, + /// Full ssh command for git operations, stored verbatim in core.sshCommand + #[arg(long, value_name = "COMMAND", conflicts_with_all = ["ssh_key", "no_ssh"])] + pub ssh_command: Option, + /// Remove the SSH command (core.sshCommand) + #[arg(long)] + pub no_ssh: bool, } #[derive(Args)] @@ -169,6 +196,26 @@ pub struct WhichArgs { pub json: bool, } +/// Backend git uses to sign commits/tags, mapped to `gpg.format`. `openpgp` +/// is git's default; `ssh` enables SSH signing (git >= 2.34). +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum SigningFormat { + Openpgp, + Ssh, + X509, +} + +impl SigningFormat { + /// The exact value written to `gpg.format`. + pub fn as_str(self) -> &'static str { + match self { + SigningFormat::Openpgp => "openpgp", + SigningFormat::Ssh => "ssh", + SigningFormat::X509 => "x509", + } + } +} + /// Shells supported by `git-id completions`: the ones natively covered by /// clap_complete, plus Nushell via clap_complete_nushell. #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] diff --git a/src/commands/create.rs b/src/commands/create.rs index 945ee8a..7e29041 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -21,7 +21,10 @@ pub fn run(env: &Env, args: &CreateArgs) -> Result { let pure_interactive = args.user_name.is_none() && args.email.is_none() && args.signing_key.is_none() - && !args.sign; + && !args.sign + && args.format.is_none() + && args.ssh_key.is_none() + && args.ssh_command.is_none(); let user_name = match &args.user_name { Some(v) => { @@ -62,6 +65,9 @@ pub fn run(env: &Env, args: &CreateArgs) -> Result { let sign = args.sign || (pure_interactive && prompt::confirm("Sign commits by default (commit.gpgsign=true)?", false)?); + let format = args.format.map(|f| f.as_str().to_string()); + let ssh_command = + store::resolve_ssh_command(args.ssh_key.as_deref(), args.ssh_command.as_deref())?; let id = store::Identity { name: args.name.clone(), @@ -69,6 +75,8 @@ pub fn run(env: &Env, args: &CreateArgs) -> Result { email, signing_key, sign, + format, + ssh_command, }; store::write_new(env, &id, args.force)?; println!( diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index e671c13..36ee54e 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -155,8 +155,21 @@ pub fn run(env: &Env) -> Result { "identity file `{name}.gitconfig` does not follow the naming rules (lowercase slug)" )); } - if let Err(err) = store::load(env, name) { - d.error(&format!("{err:#}")); + match store::load(env, name) { + Ok(id) => { + if let Some(format) = &id.format { + // A hand-edited fragment can hold a bogus `gpg.format`; git + // would only fail at signing time. + if let Err(err) = store::validate_format(format) { + d.warn(&format!("identity `{name}`: {err:#}")); + } else if format == "ssh" && (major, minor) < (2, 34) { + d.warn(&format!( + "identity `{name}` uses gpg.format=ssh, which needs git >= 2.34 to sign (you have {major}.{minor}.{patch})" + )); + } + } + } + Err(err) => d.error(&format!("{err:#}")), } if model.gitdirs_for_identity(name).is_empty() { d.info(&format!( diff --git a/src/commands/edit.rs b/src/commands/edit.rs index f4c05e5..9c3d416 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -23,6 +23,16 @@ pub fn run(env: &Env, args: &EditArgs) -> Result { } else { None }, + format: if args.no_format { + Some(String::new()) + } else { + args.format.map(|f| f.as_str().to_string()) + }, + ssh_command: if args.no_ssh { + Some(String::new()) + } else { + store::resolve_ssh_command(args.ssh_key.as_deref(), args.ssh_command.as_deref())? + }, }; if !patch.is_empty() { diff --git a/src/commands/list.rs b/src/commands/list.rs index 20d95d0..9e16c88 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -41,6 +41,8 @@ pub fn run(env: &Env, args: &ListArgs) -> Result { email: id.email, signing_key: id.signing_key, sign: id.sign, + format: id.format, + ssh_command: id.ssh_command, }, routes: model .gitdirs_for_identity(name) diff --git a/src/commands/show.rs b/src/commands/show.rs index 21293ee..85298ec 100644 --- a/src/commands/show.rs +++ b/src/commands/show.rs @@ -27,6 +27,8 @@ pub fn run(env: &Env, args: &ShowArgs) -> Result { email: id.email, signing_key: id.signing_key, sign: id.sign, + format: id.format, + ssh_command: id.ssh_command, }, routes: dirs, })?; @@ -52,6 +54,12 @@ pub fn run(env: &Env, args: &ShowArgs) -> Result { } else if id.sign { println!("signing: commit.gpgsign=true"); } + if let Some(format) = &id.format { + println!("format: {format} (gpg.format)"); + } + if let Some(cmd) = &id.ssh_command { + println!("ssh: {cmd}"); + } if dirs.is_empty() { println!("routes: (none)"); } else { diff --git a/src/output.rs b/src/output.rs index 5c298e4..fd9a323 100644 --- a/src/output.rs +++ b/src/output.rs @@ -11,6 +11,10 @@ pub struct UserJson { pub email: String, pub signing_key: Option, pub sign: bool, + /// gpg.format, when set. + pub format: Option, + /// core.sshCommand, when set. + pub ssh_command: Option, } /// One element of `git-id list --json`; also the shape of `show --json`. diff --git a/src/store.rs b/src/store.rs index e3c2120..f8c5e3e 100644 --- a/src/store.rs +++ b/src/store.rs @@ -28,6 +28,11 @@ pub struct Identity { pub signing_key: Option, /// commit.gpgsign pub sign: bool, + /// gpg.format (signing backend): `openpgp`, `ssh` or `x509`. + pub format: Option, + /// core.sshCommand: the command git runs in place of `ssh` for this + /// identity's git operations (per-identity SSH key). + pub ssh_command: Option, } /// Partial update applied by `git-id edit` with flags. @@ -38,6 +43,10 @@ pub struct IdentityPatch { /// `Some("")` removes the signing key. pub signing_key: Option, pub sign: Option, + /// `Some("")` removes gpg.format. + pub format: Option, + /// `Some("")` removes core.sshCommand. + pub ssh_command: Option, } impl IdentityPatch { @@ -46,6 +55,8 @@ impl IdentityPatch { && self.email.is_none() && self.signing_key.is_none() && self.sign.is_none() + && self.format.is_none() + && self.ssh_command.is_none() } } @@ -110,6 +121,64 @@ pub fn validate_signing_key(key: &str) -> Result<()> { Ok(()) } +/// `core.sshCommand` is free-form (a shell command line), but like a signing +/// key it must not carry control characters: a newline would let the value +/// break out of its line and inject an arbitrary gitconfig section. +/// `quote_cfg_value` escapes such characters at render time too; this is the +/// first line of defense. Callers pass only non-empty commands here. +pub fn validate_ssh_command(cmd: &str) -> Result<()> { + ensure!( + !cmd.chars().any(char::is_control), + "ssh command cannot contain control characters" + ); + Ok(()) +} + +/// `gpg.format` must be one of git's three signing backends. +pub fn validate_format(format: &str) -> Result<()> { + ensure!( + matches!(format, "openpgp" | "ssh" | "x509"), + "signing format must be one of openpgp, ssh or x509 (got `{format}`)" + ); + Ok(()) +} + +/// Build the `core.sshCommand` value for `--ssh-key `: force git to use +/// exactly this key (`IdentitiesOnly=yes`, so an agent key can't shadow it). +/// The path is single-quoted for the shell git runs the command through (a +/// POSIX `sh` everywhere, including Git for Windows), so spaces and shell +/// metacharacters in the path stay literal. +pub fn ssh_command_for_key(path: &str) -> String { + format!("ssh -i {} -o IdentitiesOnly=yes", shell_single_quote(path)) +} + +/// POSIX single-quote: wrap in `'…'`, encoding any embedded `'` as `'\''`. +fn shell_single_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// Resolve the `core.sshCommand` value from the two mutually exclusive +/// `--ssh-key` / `--ssh-command` flags (clap already forbids passing both). +/// `--ssh-key` is sugar for a deterministic command; `--ssh-command` is stored +/// verbatim. Returns `None` when neither is given. Empty values are rejected — +/// removal is `git-id edit --no-ssh`, handled by the caller. +pub fn resolve_ssh_command( + ssh_key: Option<&str>, + ssh_command: Option<&str>, +) -> Result> { + if let Some(path) = ssh_key { + ensure!(!path.is_empty(), "ssh key path cannot be empty"); + validate_ssh_command(path)?; + Ok(Some(ssh_command_for_key(path))) + } else if let Some(cmd) = ssh_command { + ensure!(!cmd.is_empty(), "ssh command cannot be empty"); + validate_ssh_command(cmd)?; + Ok(Some(cmd.to_string())) + } else { + Ok(None) + } +} + pub fn fragment_path(env: &Env, name: &str) -> PathBuf { env.identities_dir.join(format!("{name}.{FRAGMENT_EXT}")) } @@ -158,12 +227,16 @@ pub fn load(env: &Env, name: &str) -> Result { })?; let signing_key = gitcfg::get_file(&path, "user.signingkey")?; let sign = gitcfg::get_file_bool(&path, "commit.gpgsign")?.unwrap_or(false); + let format = gitcfg::get_file(&path, "gpg.format")?; + let ssh_command = gitcfg::get_file(&path, "core.sshCommand")?; Ok(Identity { name: name.to_string(), user_name, email, signing_key, sign, + format, + ssh_command, }) } @@ -178,9 +251,18 @@ pub fn render_fragment(id: &Identity) -> String { if let Some(key) = &id.signing_key { out.push_str(&format!("\tsigningkey = {}\n", quote_cfg_value(key))); } + if let Some(format) = &id.format { + out.push_str(&format!("[gpg]\n\tformat = {}\n", quote_cfg_value(format))); + } if id.sign { out.push_str("[commit]\n\tgpgsign = true\n"); } + if let Some(cmd) = &id.ssh_command { + out.push_str(&format!( + "[core]\n\tsshCommand = {}\n", + quote_cfg_value(cmd) + )); + } out } @@ -220,6 +302,12 @@ pub fn write_new(env: &Env, id: &Identity, force: bool) -> Result<()> { if let Some(key) = &id.signing_key { validate_signing_key(key)?; } + if let Some(format) = &id.format { + validate_format(format)?; + } + if let Some(cmd) = &id.ssh_command { + validate_ssh_command(cmd)?; + } let path = fragment_path(env, &id.name); if path.exists() && !force { bail!( @@ -252,6 +340,16 @@ pub fn apply_patch(env: &Env, name: &str, patch: &IdentityPatch) -> Result<()> { validate_signing_key(key)?; } } + if let Some(format) = &patch.format { + if !format.is_empty() { + validate_format(format)?; + } + } + if let Some(cmd) = &patch.ssh_command { + if !cmd.is_empty() { + validate_ssh_command(cmd)?; + } + } if let Some(user_name) = &patch.user_name { gitcfg::set_file(&path, "user.name", user_name)?; } @@ -268,6 +366,20 @@ pub fn apply_patch(env: &Env, name: &str, patch: &IdentityPatch) -> Result<()> { if let Some(sign) = patch.sign { gitcfg::set_file(&path, "commit.gpgsign", if sign { "true" } else { "false" })?; } + if let Some(format) = &patch.format { + if format.is_empty() { + gitcfg::unset_file(&path, "gpg.format")?; + } else { + gitcfg::set_file(&path, "gpg.format", format)?; + } + } + if let Some(cmd) = &patch.ssh_command { + if cmd.is_empty() { + gitcfg::unset_file(&path, "core.sshCommand")?; + } else { + gitcfg::set_file(&path, "core.sshCommand", cmd)?; + } + } Ok(()) } @@ -328,6 +440,8 @@ mod tests { email: "jane@work.example".into(), signing_key: None, sign: false, + format: None, + ssh_command: None, }; assert_eq!( render_fragment(&id), @@ -345,6 +459,59 @@ mod tests { ); } + #[test] + fn renders_gpg_format_and_ssh_command_sections() { + let id = Identity { + name: "work".into(), + user_name: "Jane Doe".into(), + email: "jane@work.example".into(), + signing_key: Some("ABCDEF12".into()), + sign: true, + format: Some("ssh".into()), + ssh_command: Some("ssh -i '/home/jane/.ssh/id_work' -o IdentitiesOnly=yes".into()), + }; + assert_eq!( + render_fragment(&id), + "# git-id identity: work\n[user]\n\tname = Jane Doe\n\temail = jane@work.example\n\ + \tsigningkey = ABCDEF12\n[gpg]\n\tformat = ssh\n[commit]\n\tgpgsign = true\n\ + [core]\n\tsshCommand = ssh -i '/home/jane/.ssh/id_work' -o IdentitiesOnly=yes\n" + ); + } + + #[test] + fn format_validation() { + for ok in ["openpgp", "ssh", "x509"] { + assert!(validate_format(ok).is_ok(), "{ok} should be valid"); + } + for bad in ["", "OpenPGP", "gpg", "rsa", "ssh "] { + assert!(validate_format(bad).is_err(), "{bad} should be invalid"); + } + } + + #[test] + fn ssh_command_for_key_shell_quotes_the_path() { + assert_eq!( + ssh_command_for_key("/home/jane/.ssh/id_work"), + "ssh -i '/home/jane/.ssh/id_work' -o IdentitiesOnly=yes" + ); + // Spaces stay literal inside the single quotes. + assert_eq!( + ssh_command_for_key("/home/jane/My Keys/id"), + "ssh -i '/home/jane/My Keys/id' -o IdentitiesOnly=yes" + ); + // An embedded apostrophe is closed, escaped, and reopened. + assert_eq!( + ssh_command_for_key("/home/o'brien/id"), + "ssh -i '/home/o'\\''brien/id' -o IdentitiesOnly=yes" + ); + } + + #[test] + fn ssh_command_rejects_control_chars() { + assert!(validate_ssh_command("ssh -i ~/.ssh/id -p 2222").is_ok()); + assert!(validate_ssh_command("ssh\n[user]\n\temail = evil@x.co").is_err()); + } + #[test] fn values_needing_quotes_are_escaped() { assert_eq!(quote_cfg_value("Jane #1"), "\"Jane #1\""); @@ -376,6 +543,8 @@ mod tests { email: "j@b.co".into(), signing_key: Some("KEY\n[core]\n\tsshCommand = touch PWNED".into()), sign: false, + format: None, + ssh_command: None, }; atomic_write(&path, &render_fragment(&id)).unwrap(); assert_eq!( @@ -397,6 +566,8 @@ mod tests { email: "q@b.co".into(), signing_key: None, sign: false, + format: None, + ssh_command: None, }; atomic_write(&path, &render_fragment(&id)).unwrap(); assert_eq!( diff --git a/tests/cli.rs b/tests/cli.rs index e862ae2..b19799c 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -842,6 +842,37 @@ fn doctor_flags_missing_fragment_and_missing_include() { .stdout(contains("git-id init")); } +#[test] +fn doctor_flags_invalid_gpg_format() { + let t = TestEnv::new(); + t.ok(&["init"]); + t.ok(&[ + "create", + "work", + "--name", + "Jane Doe", + "--email", + "jane@work.example", + ]); + // A hand-edited fragment can hold a bogus gpg.format value (git only fails + // at signing time); doctor flags it as a warning, not a hard error. + t.git_ok( + &t.home, + &[ + "config", + "--file", + t.fragment("work").to_str().unwrap(), + "gpg.format", + "pgp", + ], + ); + t.cmd() + .arg("doctor") + .assert() + .success() + .stdout(contains("openpgp, ssh or x509")); +} + #[test] fn completions_generate_for_bash_and_zsh() { let t = TestEnv::new(); @@ -965,6 +996,151 @@ fn edit_removes_signing_key_and_toggles_sign_preserving_manual_keys() { ); } +// --------------------------------------------------------------------------- +// gpg.format and per-identity SSH key (core.sshCommand) + +#[test] +fn create_sets_gpg_format_and_ssh_key() { + let t = TestEnv::new(); + t.ok(&["init"]); + t.ok(&[ + "create", + "work", + "--name", + "Jane Doe", + "--email", + "jane@work.example", + "--format", + "ssh", + "--ssh-key", + "/home/jane/.ssh/id_work", + ]); + + // `--ssh-key` is sugar for a deterministic, agent-proof command. + assert_eq!( + t.read(&t.fragment("work")), + "# git-id identity: work\n\ + [user]\n\ + \tname = Jane Doe\n\ + \temail = jane@work.example\n\ + [gpg]\n\ + \tformat = ssh\n\ + [core]\n\ + \tsshCommand = ssh -i '/home/jane/.ssh/id_work' -o IdentitiesOnly=yes\n" + ); + + let json: serde_json::Value = serde_json::from_str(&t.ok(&["show", "work", "--json"])).unwrap(); + assert_eq!(json["user"]["format"], "ssh"); + assert_eq!( + json["user"]["ssh_command"], + "ssh -i '/home/jane/.ssh/id_work' -o IdentitiesOnly=yes" + ); +} + +#[test] +fn create_stores_raw_ssh_command_verbatim() { + let t = TestEnv::new(); + t.ok(&["init"]); + t.ok(&[ + "create", + "work", + "--name", + "Jane Doe", + "--email", + "jane@work.example", + "--ssh-command", + "ssh -i ~/.ssh/id_work -p 2222", + ]); + + let json: serde_json::Value = serde_json::from_str(&t.ok(&["show", "work", "--json"])).unwrap(); + assert_eq!(json["user"]["ssh_command"], "ssh -i ~/.ssh/id_work -p 2222"); + assert_eq!(json["user"]["format"], serde_json::Value::Null); +} + +#[test] +fn ssh_key_and_ssh_command_are_mutually_exclusive() { + let t = TestEnv::new(); + t.ok(&["init"]); + t.cmd() + .args([ + "create", + "work", + "--name", + "Jane", + "--email", + "jane@work.example", + "--ssh-key", + "/k", + "--ssh-command", + "ssh -i /k", + ]) + .assert() + .failure(); + assert!(!t.fragment("work").exists()); +} + +#[test] +fn create_rejects_control_chars_in_ssh_command() { + let t = TestEnv::new(); + t.ok(&["init"]); + // A newline would otherwise inject a gitconfig section into the fragment. + t.cmd() + .args([ + "create", + "work", + "--name", + "Jane", + "--email", + "jane@work.example", + "--ssh-command", + "ssh\n[user]\n\temail = evil@x.co", + ]) + .assert() + .failure() + .stderr(contains("control characters")); + assert!(!t.fragment("work").exists()); +} + +#[test] +fn edit_sets_and_removes_format_and_ssh() { + let t = TestEnv::new(); + t.ok(&["init"]); + t.ok(&[ + "create", + "work", + "--name", + "Jane Doe", + "--email", + "jane@work.example", + ]); + + t.ok(&[ + "edit", + "work", + "--format", + "ssh", + "--ssh-key", + "/home/jane/.ssh/id_work", + ]); + let json: serde_json::Value = serde_json::from_str(&t.ok(&["show", "work", "--json"])).unwrap(); + assert_eq!(json["user"]["format"], "ssh"); + assert_eq!( + json["user"]["ssh_command"], + "ssh -i '/home/jane/.ssh/id_work' -o IdentitiesOnly=yes" + ); + + t.ok(&["edit", "work", "--no-format", "--no-ssh"]); + let json: serde_json::Value = serde_json::from_str(&t.ok(&["show", "work", "--json"])).unwrap(); + assert_eq!(json["user"]["format"], serde_json::Value::Null); + assert_eq!(json["user"]["ssh_command"], serde_json::Value::Null); + + let content = t.read(&t.fragment("work")); + assert!( + !content.contains("format") && !content.contains("sshCommand"), + "format/ssh not removed:\n{content}" + ); +} + #[test] fn edit_rejects_invalid_field_without_partial_write() { let t = TestEnv::new();