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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <openpgp|ssh|x509>` (`gpg.format`, for SSH commit signing on
git ≥ 2.34), `--ssh-key <path>` — shorthand for
`core.sshCommand = ssh -i <path> -o IdentitiesOnly=yes`, so a per-identity key
is used and an agent key cannot shadow it — and `--ssh-command <cmd>` 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
Expand Down
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down Expand Up @@ -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 <name>` | Create an identity (`--name`, `--email`, `--signing-key`, `--sign`; prompts interactively if omitted). |
| `git id create <name>` | 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 <name>` | Full detail of one identity, including the raw fragment. |
| `git id edit <name>` | Update via flags, or open the fragment in `$EDITOR` when no flags are given. Hand-added keys survive. |
Expand All @@ -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 <openpgp|ssh|x509>` sets `gpg.format` (SSH signing needs git ≥ 2.34).
- `--ssh-key <path>` is shorthand: git-id writes
`core.sshCommand = ssh -i <path> -o IdentitiesOnly=yes`, so that exact key is
used and an agent key can't shadow it. Use `--ssh-command "<cmd>"` 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`:
Expand Down Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SigningFormat>,
/// SSH key file for this identity's git operations; sets
/// `core.sshCommand = ssh -i <PATH> -o IdentitiesOnly=yes`
#[arg(long, value_name = "PATH", conflicts_with = "ssh_command")]
pub ssh_key: Option<String>,
/// 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<String>,
/// Overwrite the identity if it already exists
#[arg(long)]
pub force: bool,
Expand Down Expand Up @@ -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<SigningFormat>,
/// 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 <PATH> -o IdentitiesOnly=yes`
#[arg(long, value_name = "PATH", conflicts_with_all = ["ssh_command", "no_ssh"])]
pub ssh_key: Option<String>,
/// 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<String>,
/// Remove the SSH command (core.sshCommand)
#[arg(long)]
pub no_ssh: bool,
}

#[derive(Args)]
Expand Down Expand Up @@ -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)]
Expand Down
10 changes: 9 additions & 1 deletion src/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ pub fn run(env: &Env, args: &CreateArgs) -> Result<ExitCode> {
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) => {
Expand Down Expand Up @@ -62,13 +65,18 @@ pub fn run(env: &Env, args: &CreateArgs) -> Result<ExitCode> {
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(),
user_name,
email,
signing_key,
sign,
format,
ssh_command,
};
store::write_new(env, &id, args.force)?;
println!(
Expand Down
17 changes: 15 additions & 2 deletions src/commands/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,21 @@ pub fn run(env: &Env) -> Result<ExitCode> {
"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!(
Expand Down
10 changes: 10 additions & 0 deletions src/commands/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ pub fn run(env: &Env, args: &EditArgs) -> Result<ExitCode> {
} 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() {
Expand Down
2 changes: 2 additions & 0 deletions src/commands/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ pub fn run(env: &Env, args: &ListArgs) -> Result<ExitCode> {
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)
Expand Down
8 changes: 8 additions & 0 deletions src/commands/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pub fn run(env: &Env, args: &ShowArgs) -> Result<ExitCode> {
email: id.email,
signing_key: id.signing_key,
sign: id.sign,
format: id.format,
ssh_command: id.ssh_command,
},
routes: dirs,
})?;
Expand All @@ -52,6 +54,12 @@ pub fn run(env: &Env, args: &ShowArgs) -> Result<ExitCode> {
} 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 {
Expand Down
4 changes: 4 additions & 0 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ pub struct UserJson {
pub email: String,
pub signing_key: Option<String>,
pub sign: bool,
/// gpg.format, when set.
pub format: Option<String>,
/// core.sshCommand, when set.
pub ssh_command: Option<String>,
}

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