Skip to content

Commit ec70dee

Browse files
authored
Merge pull request #2 from jmarette/feat/per-identity-signing-ssh
feat: per-identity signing format and SSH key
2 parents ef57868 + 1239efe commit ec70dee

11 files changed

Lines changed: 480 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Identities can carry per-directory signing and SSH settings. `create`/`edit`
13+
gain `--format <openpgp|ssh|x509>` (`gpg.format`, for SSH commit signing on
14+
git ≥ 2.34), `--ssh-key <path>` — shorthand for
15+
`core.sshCommand = ssh -i <path> -o IdentitiesOnly=yes`, so a per-identity key
16+
is used and an agent key cannot shadow it — and `--ssh-command <cmd>` to store
17+
a full `core.sshCommand` verbatim. `edit --no-format` / `--no-ssh` remove them.
18+
Both new values are surfaced in `show`/`list --json` (`user.format`,
19+
`user.ssh_command`) and are rejected if they carry control characters.
20+
- `doctor` flags an identity whose `gpg.format` is not one of openpgp/ssh/x509,
21+
and warns when `gpg.format = ssh` but the installed git is older than 2.34
22+
(which cannot sign with SSH).
23+
1024
### Fixed
1125

1226
- `doctor` no longer reports every routed directory as differing from its

‎README.md‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ under `${XDG_CONFIG_HOME:-~/.config}/git-id/`:
3434
path = "/Users/you/.config/git-id/identities/work.gitconfig"
3535
3636
~/.config/git-id/identities/work.gitconfig ← one fragment per identity
37-
└─ [user] name / email / signingkey, [commit] gpgsign
37+
└─ [user] name / email / signingkey, [gpg] format,
38+
[commit] gpgsign, [core] sshCommand
3839
```
3940

4041
A route on a directory applies to it **and every repository below it**.
@@ -93,7 +94,7 @@ guessing one from your hostname.
9394
| Command | What it does |
9495
|---|---|
9596
| `git id init` | One-time setup (idempotent, backs up your gitconfig first). |
96-
| `git id create <name>` | Create an identity (`--name`, `--email`, `--signing-key`, `--sign`; prompts interactively if omitted). |
97+
| `git id create <name>` | Create an identity (`--name`, `--email`, `--signing-key`, `--sign`, `--format`, `--ssh-key`/`--ssh-command`; prompts for the basics if omitted). |
9798
| `git id list` | Identities and the directories routed to them (`--paths` for the dir → identity map). |
9899
| `git id show <name>` | Full detail of one identity, including the raw fragment. |
99100
| `git id edit <name>` | 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.
107108

108109
Every command has a detailed `--help`.
109110

111+
### Signing and SSH keys
112+
113+
An identity can carry its signing and transport settings, so they follow the
114+
directory like the name and email do:
115+
116+
```console
117+
$ git id create work --name "Jane Doe" --email jane@work.example \
118+
--signing-key ABCDEF12 --sign --format ssh \
119+
--ssh-key ~/.ssh/id_work
120+
```
121+
122+
- `--format <openpgp|ssh|x509>` sets `gpg.format` (SSH signing needs git ≥ 2.34).
123+
- `--ssh-key <path>` is shorthand: git-id writes
124+
`core.sshCommand = ssh -i <path> -o IdentitiesOnly=yes`, so that exact key is
125+
used and an agent key can't shadow it. Use `--ssh-command "<cmd>"` instead to
126+
store a full command verbatim (custom port, proxy, …).
127+
128+
On `edit`, `--no-format` and `--no-ssh` remove those settings; `--signing-key ""`
129+
removes the key. Anything you add to a fragment by hand is preserved.
130+
110131
### Scripting
111132

112133
`list`, `show` and `which` take `--json`:
@@ -204,8 +225,7 @@ the binary and a real `git` inside its own temporary `HOME`.
204225

205226
Notable changes are tracked in [CHANGELOG.md](CHANGELOG.md).
206227

207-
Future work: routing by remote URL (`hasconfig:remote.*.url`), per-identity
208-
SSH key management, GPG/SSH signing helpers, `doctor --fix`.
228+
Future work: routing by remote URL (`hasconfig:remote.*.url`), `doctor --fix`.
209229

210230
## Contributing
211231

‎src/cli.rs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ pub struct CreateArgs {
8585
/// Sign commits by default (sets commit.gpgsign=true)
8686
#[arg(long)]
8787
pub sign: bool,
88+
/// Signing format (gpg.format): openpgp (default), ssh or x509
89+
#[arg(long, value_enum, value_name = "FORMAT")]
90+
pub format: Option<SigningFormat>,
91+
/// SSH key file for this identity's git operations; sets
92+
/// `core.sshCommand = ssh -i <PATH> -o IdentitiesOnly=yes`
93+
#[arg(long, value_name = "PATH", conflicts_with = "ssh_command")]
94+
pub ssh_key: Option<String>,
95+
/// Full ssh command for this identity's git operations, stored verbatim in
96+
/// core.sshCommand (escape hatch for custom ports, proxies, …)
97+
#[arg(long, value_name = "COMMAND", conflicts_with = "ssh_key")]
98+
pub ssh_command: Option<String>,
8899
/// Overwrite the identity if it already exists
89100
#[arg(long)]
90101
pub force: bool,
@@ -128,6 +139,22 @@ pub struct EditArgs {
128139
/// Do not sign commits by default (sets commit.gpgsign=false)
129140
#[arg(long)]
130141
pub no_sign: bool,
142+
/// New signing format (gpg.format): openpgp, ssh or x509
143+
#[arg(long, value_enum, value_name = "FORMAT", conflicts_with = "no_format")]
144+
pub format: Option<SigningFormat>,
145+
/// Remove the signing format (gpg.format), reverting to git's default
146+
#[arg(long)]
147+
pub no_format: bool,
148+
/// SSH key file for git operations; sets
149+
/// `core.sshCommand = ssh -i <PATH> -o IdentitiesOnly=yes`
150+
#[arg(long, value_name = "PATH", conflicts_with_all = ["ssh_command", "no_ssh"])]
151+
pub ssh_key: Option<String>,
152+
/// Full ssh command for git operations, stored verbatim in core.sshCommand
153+
#[arg(long, value_name = "COMMAND", conflicts_with_all = ["ssh_key", "no_ssh"])]
154+
pub ssh_command: Option<String>,
155+
/// Remove the SSH command (core.sshCommand)
156+
#[arg(long)]
157+
pub no_ssh: bool,
131158
}
132159

133160
#[derive(Args)]
@@ -169,6 +196,26 @@ pub struct WhichArgs {
169196
pub json: bool,
170197
}
171198

199+
/// Backend git uses to sign commits/tags, mapped to `gpg.format`. `openpgp`
200+
/// is git's default; `ssh` enables SSH signing (git >= 2.34).
201+
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
202+
pub enum SigningFormat {
203+
Openpgp,
204+
Ssh,
205+
X509,
206+
}
207+
208+
impl SigningFormat {
209+
/// The exact value written to `gpg.format`.
210+
pub fn as_str(self) -> &'static str {
211+
match self {
212+
SigningFormat::Openpgp => "openpgp",
213+
SigningFormat::Ssh => "ssh",
214+
SigningFormat::X509 => "x509",
215+
}
216+
}
217+
}
218+
172219
/// Shells supported by `git-id completions`: the ones natively covered by
173220
/// clap_complete, plus Nushell via clap_complete_nushell.
174221
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]

‎src/commands/create.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ pub fn run(env: &Env, args: &CreateArgs) -> Result<ExitCode> {
2121
let pure_interactive = args.user_name.is_none()
2222
&& args.email.is_none()
2323
&& args.signing_key.is_none()
24-
&& !args.sign;
24+
&& !args.sign
25+
&& args.format.is_none()
26+
&& args.ssh_key.is_none()
27+
&& args.ssh_command.is_none();
2528

2629
let user_name = match &args.user_name {
2730
Some(v) => {
@@ -62,13 +65,18 @@ pub fn run(env: &Env, args: &CreateArgs) -> Result<ExitCode> {
6265
let sign = args.sign
6366
|| (pure_interactive
6467
&& prompt::confirm("Sign commits by default (commit.gpgsign=true)?", false)?);
68+
let format = args.format.map(|f| f.as_str().to_string());
69+
let ssh_command =
70+
store::resolve_ssh_command(args.ssh_key.as_deref(), args.ssh_command.as_deref())?;
6571

6672
let id = store::Identity {
6773
name: args.name.clone(),
6874
user_name,
6975
email,
7076
signing_key,
7177
sign,
78+
format,
79+
ssh_command,
7280
};
7381
store::write_new(env, &id, args.force)?;
7482
println!(

‎src/commands/doctor.rs‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,21 @@ pub fn run(env: &Env) -> Result<ExitCode> {
155155
"identity file `{name}.gitconfig` does not follow the naming rules (lowercase slug)"
156156
));
157157
}
158-
if let Err(err) = store::load(env, name) {
159-
d.error(&format!("{err:#}"));
158+
match store::load(env, name) {
159+
Ok(id) => {
160+
if let Some(format) = &id.format {
161+
// A hand-edited fragment can hold a bogus `gpg.format`; git
162+
// would only fail at signing time.
163+
if let Err(err) = store::validate_format(format) {
164+
d.warn(&format!("identity `{name}`: {err:#}"));
165+
} else if format == "ssh" && (major, minor) < (2, 34) {
166+
d.warn(&format!(
167+
"identity `{name}` uses gpg.format=ssh, which needs git >= 2.34 to sign (you have {major}.{minor}.{patch})"
168+
));
169+
}
170+
}
171+
}
172+
Err(err) => d.error(&format!("{err:#}")),
160173
}
161174
if model.gitdirs_for_identity(name).is_empty() {
162175
d.info(&format!(

‎src/commands/edit.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ pub fn run(env: &Env, args: &EditArgs) -> Result<ExitCode> {
2323
} else {
2424
None
2525
},
26+
format: if args.no_format {
27+
Some(String::new())
28+
} else {
29+
args.format.map(|f| f.as_str().to_string())
30+
},
31+
ssh_command: if args.no_ssh {
32+
Some(String::new())
33+
} else {
34+
store::resolve_ssh_command(args.ssh_key.as_deref(), args.ssh_command.as_deref())?
35+
},
2636
};
2737

2838
if !patch.is_empty() {

‎src/commands/list.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ pub fn run(env: &Env, args: &ListArgs) -> Result<ExitCode> {
4141
email: id.email,
4242
signing_key: id.signing_key,
4343
sign: id.sign,
44+
format: id.format,
45+
ssh_command: id.ssh_command,
4446
},
4547
routes: model
4648
.gitdirs_for_identity(name)

‎src/commands/show.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pub fn run(env: &Env, args: &ShowArgs) -> Result<ExitCode> {
2727
email: id.email,
2828
signing_key: id.signing_key,
2929
sign: id.sign,
30+
format: id.format,
31+
ssh_command: id.ssh_command,
3032
},
3133
routes: dirs,
3234
})?;
@@ -52,6 +54,12 @@ pub fn run(env: &Env, args: &ShowArgs) -> Result<ExitCode> {
5254
} else if id.sign {
5355
println!("signing: commit.gpgsign=true");
5456
}
57+
if let Some(format) = &id.format {
58+
println!("format: {format} (gpg.format)");
59+
}
60+
if let Some(cmd) = &id.ssh_command {
61+
println!("ssh: {cmd}");
62+
}
5563
if dirs.is_empty() {
5664
println!("routes: (none)");
5765
} else {

‎src/output.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ pub struct UserJson {
1111
pub email: String,
1212
pub signing_key: Option<String>,
1313
pub sign: bool,
14+
/// gpg.format, when set.
15+
pub format: Option<String>,
16+
/// core.sshCommand, when set.
17+
pub ssh_command: Option<String>,
1418
}
1519

1620
/// One element of `git-id list --json`; also the shape of `show --json`.

0 commit comments

Comments
 (0)