1use crate::config::{
12 Config, ConfigStore, GitRepoDef, GithubCredentialSelection, GithubIdentityDef,
13};
14use crate::github_auth;
15use anyhow::Result;
16use serde::Serialize;
17use std::collections::HashMap;
18use tokio::process::Command;
19
20#[derive(Debug, Clone, Default, Serialize)]
22#[serde(rename_all = "camelCase")]
23pub struct MachineIdentity {
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub name: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub email: Option<String>,
28}
29
30#[derive(Debug, Clone, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct GitIdentityState {
33 pub selected: Option<GithubIdentityDef>,
35 pub machine: MachineIdentity,
36 pub diverged: bool,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub reason: Option<String>,
41}
42
43pub fn identity_env(identity: &GithubIdentityDef) -> HashMap<String, String> {
46 HashMap::from([
47 ("GIT_AUTHOR_NAME".into(), identity.name.clone()),
48 ("GIT_AUTHOR_EMAIL".into(), identity.email.clone()),
49 ("GIT_COMMITTER_NAME".into(), identity.name.clone()),
50 ("GIT_COMMITTER_EMAIL".into(), identity.email.clone()),
51 ])
52}
53
54pub async fn resolve_identity_for_cwd(store: &ConfigStore, cwd: &str) -> Result<GitIdentityState> {
59 let config = store.load().await?;
60 let repository = repository_for_cwd(&config, cwd).await;
61 Ok(resolve_identity_state(store, &config, repository, cwd).await)
62}
63
64pub async fn repository_for_cwd<'a>(config: &'a Config, cwd: &str) -> Option<&'a GitRepoDef> {
69 let top_level = crate::repo_match::git_toplevel(cwd).await?;
70 crate::repo_match::match_registered_repository(config, &top_level)
71 .await
72 .ok()
73 .flatten()
74}
75
76pub async fn resolve_identity_state(
77 store: &ConfigStore,
78 config: &Config,
79 repository: Option<&GitRepoDef>,
80 cwd: &str,
81) -> GitIdentityState {
82 let machine = machine_identity(cwd).await;
83
84 let Some(repository) = repository else {
85 return GitIdentityState {
86 selected: None,
87 machine,
88 diverged: false,
89 reason: Some("No GitHub account is selected for this repository.".into()),
90 };
91 };
92 if repository.github_credential.is_none() {
93 return GitIdentityState {
94 selected: None,
95 machine,
96 diverged: false,
97 reason: Some("No GitHub account is selected for this repository.".into()),
98 };
99 }
100
101 match resolve_selected_identity(store, config, repository).await {
102 Ok(selected) => {
103 let diverged = is_diverged(&selected, &machine);
104 GitIdentityState {
105 selected: Some(selected),
106 machine,
107 diverged,
108 reason: None,
109 }
110 }
111 Err(reason) => GitIdentityState {
112 selected: None,
113 machine,
114 diverged: false,
115 reason: Some(reason),
116 },
117 }
118}
119
120pub async fn resolve_selected_identity(
123 store: &ConfigStore,
124 config: &Config,
125 repository: &GitRepoDef,
126) -> Result<GithubIdentityDef, String> {
127 let selection = repository
128 .github_credential
129 .as_ref()
130 .ok_or("No GitHub account is selected for this repository.")?;
131
132 let (host, known_login) = match selection {
133 GithubCredentialSelection::Gh { host, login } => (host.clone(), Some(login.clone())),
134 GithubCredentialSelection::Stored { host } => (
135 host.clone(),
136 config
137 .github_tokens
138 .iter()
139 .find(|entry| &entry.host == host)
140 .and_then(|entry| entry.login.clone()),
141 ),
142 };
143
144 if let Some(login) = &known_login {
145 if let Some(cached) = store.get_github_identity(config, &host, login) {
146 return Ok(cached.clone());
147 }
148 }
149
150 let (token, _, _) = github_auth::resolve(config, Some(&repository.name), &host).await?;
151 let viewer = fetch_viewer(&token, &host).await?;
152 let login = viewer
153 .login
154 .or(known_login)
155 .ok_or("GitHub did not report an account login for the selected credential.")?;
156
157 let identity = GithubIdentityDef {
158 host,
159 name: viewer
160 .name
161 .filter(|value| !value.trim().is_empty())
162 .unwrap_or_else(|| login.clone()),
163 email: commit_email(viewer.email.as_deref(), viewer.id, &login),
164 login,
165 };
166 store
167 .set_github_identity(identity.clone())
168 .await
169 .map_err(|error| error.to_string())?;
170 Ok(identity)
171}
172
173pub async fn resolve_push_credential(
179 config: &Config,
180 repository: Option<&GitRepoDef>,
181 remote_url: Option<&str>,
182) -> Option<(String, Option<String>)> {
183 let repository = repository?;
184 let selection = repository.github_credential.as_ref()?;
185 let selected_host = match selection {
186 GithubCredentialSelection::Gh { host, .. } => host,
187 GithubCredentialSelection::Stored { host } => host,
188 };
189 let host = https_remote_host(remote_url?)?;
190 if &host != selected_host {
191 return None;
192 }
193
194 let (token, _, resolved) = github_auth::resolve(config, Some(&repository.name), &host)
195 .await
196 .ok()?;
197 let login = match resolved {
198 GithubCredentialSelection::Gh { login, .. } => Some(login),
199 GithubCredentialSelection::Stored { .. } => None,
200 };
201 Some((token, login))
202}
203
204pub fn https_remote_host(remote_url: &str) -> Option<String> {
206 let trimmed = remote_url.trim();
207 let rest = trimmed
208 .strip_prefix("https://")
209 .or_else(|| trimmed.strip_prefix("http://"))?;
210 let authority = rest.split('/').next()?;
211 let host = authority.rsplit('@').next()?.split(':').next()?;
213 if host.is_empty() {
214 None
215 } else {
216 Some(host.to_string())
217 }
218}
219
220pub fn commit_email(public_email: Option<&str>, id: Option<u64>, login: &str) -> String {
224 if let Some(email) = public_email.map(str::trim).filter(|v| !v.is_empty()) {
225 return email.to_string();
226 }
227 match id {
228 Some(id) => format!("{id}+{login}@users.noreply.github.com"),
229 None => format!("{login}@users.noreply.github.com"),
230 }
231}
232
233pub async fn machine_identity(cwd: &str) -> MachineIdentity {
234 MachineIdentity {
235 name: git_config_value(cwd, "user.name").await,
236 email: git_config_value(cwd, "user.email").await,
237 }
238}
239
240fn is_diverged(selected: &GithubIdentityDef, machine: &MachineIdentity) -> bool {
241 match machine.email.as_deref().map(str::trim) {
242 Some(configured) if !configured.is_empty() => {
243 !configured.eq_ignore_ascii_case(selected.email.trim())
244 }
245 _ => true,
246 }
247}
248
249struct Viewer {
250 login: Option<String>,
251 name: Option<String>,
252 email: Option<String>,
253 id: Option<u64>,
254}
255
256async fn fetch_viewer(token: &str, host: &str) -> Result<Viewer, String> {
257 let url = if host == "github.com" || host.is_empty() {
258 "https://api.github.com/user".to_string()
259 } else {
260 format!("https://{host}/api/v3/user")
261 };
262 let response = reqwest::Client::new()
263 .get(&url)
264 .header("Authorization", format!("token {token}"))
265 .header("User-Agent", "nomoreide")
266 .header("Accept", "application/vnd.github+json")
267 .send()
268 .await
269 .map_err(|error| error.to_string())?;
270 if !response.status().is_success() {
271 return Err(format!(
272 "GitHub rejected the credential while resolving the commit identity ({}).",
273 response.status()
274 ));
275 }
276 let body: serde_json::Value = response.json().await.map_err(|error| error.to_string())?;
277 Ok(Viewer {
278 login: body
279 .get("login")
280 .and_then(|v| v.as_str())
281 .map(str::to_string),
282 name: body
283 .get("name")
284 .and_then(|v| v.as_str())
285 .map(str::to_string),
286 email: body
287 .get("email")
288 .and_then(|v| v.as_str())
289 .map(str::to_string),
290 id: body.get("id").and_then(|v| v.as_u64()),
291 })
292}
293
294async fn git_config_value(cwd: &str, key: &str) -> Option<String> {
295 let out = Command::new("git")
298 .args(["config", "--get", key])
299 .current_dir(cwd)
300 .output()
301 .await
302 .ok()?;
303 if !out.status.success() {
304 return None;
305 }
306 let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
307 if value.is_empty() {
308 None
309 } else {
310 Some(value)
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::config::{GitRepoDef, GithubTokenDef};
318
319 #[test]
320 fn prefers_the_accounts_public_address() {
321 assert_eq!(
322 commit_email(Some("dev@example.com"), Some(42), "octocat"),
323 "dev@example.com"
324 );
325 }
326
327 #[test]
328 fn falls_back_to_the_id_qualified_noreply_address() {
329 assert_eq!(
330 commit_email(None, Some(42), "octocat"),
331 "42+octocat@users.noreply.github.com"
332 );
333 assert_eq!(
334 commit_email(Some(" "), None, "octocat"),
335 "octocat@users.noreply.github.com"
336 );
337 }
338
339 #[test]
340 fn parses_only_https_remote_hosts() {
341 assert_eq!(
342 https_remote_host("https://github.com/acme/app.git").as_deref(),
343 Some("github.com")
344 );
345 assert_eq!(
346 https_remote_host("https://user@github.com:443/acme/app.git").as_deref(),
347 Some("github.com")
348 );
349 assert!(https_remote_host("git@github.com:acme/app.git").is_none());
351 assert!(https_remote_host("ssh://git@github.com/acme/app.git").is_none());
352 assert!(https_remote_host("/local/path/repo.git").is_none());
353 }
354
355 #[test]
356 fn identity_env_pins_author_and_committer() {
357 let env = identity_env(&identity("octocat", "octo@example.com"));
358 assert_eq!(env["GIT_AUTHOR_EMAIL"], "octo@example.com");
359 assert_eq!(env["GIT_COMMITTER_EMAIL"], "octo@example.com");
360 assert_eq!(env["GIT_AUTHOR_NAME"], "Octo Cat");
361 }
362
363 #[test]
364 fn divergence_compares_email_case_insensitively() {
365 let selected = identity("work", "Work@example.test");
366 assert!(!is_diverged(
367 &selected,
368 &MachineIdentity {
369 name: None,
370 email: Some("work@example.test".into()),
371 }
372 ));
373 assert!(is_diverged(
374 &selected,
375 &MachineIdentity {
376 name: None,
377 email: Some("other@example.test".into()),
378 }
379 ));
380 assert!(is_diverged(&selected, &MachineIdentity::default()));
383 }
384
385 #[tokio::test]
386 async fn push_credential_declines_ssh_and_mismatched_hosts() {
387 let mut config = Config::default();
388 config.github_tokens.push(GithubTokenDef {
389 host: "github.com".into(),
390 token: "token".into(),
391 login: Some("work".into()),
392 avatar_url: None,
393 });
394 let repo = GitRepoDef {
395 name: "app".into(),
396 path: "/tmp/app".into(),
397 active_worktree_path: None,
398 github_credential: Some(GithubCredentialSelection::Stored {
399 host: "github.com".into(),
400 }),
401 provider_projects: None,
402 legacy_vercel_project_id: None,
403 };
404
405 assert!(
406 resolve_push_credential(&config, Some(&repo), Some("git@github.com:acme/app.git"))
407 .await
408 .is_none()
409 );
410 assert!(
411 resolve_push_credential(&config, Some(&repo), Some("https://gitlab.com/a/b.git"))
412 .await
413 .is_none()
414 );
415 }
416
417 #[tokio::test]
418 async fn push_credential_declines_when_no_account_is_selected() {
419 let config = Config::default();
420 let repo = GitRepoDef {
421 name: "app".into(),
422 path: "/tmp/app".into(),
423 active_worktree_path: None,
424 github_credential: None,
425 provider_projects: None,
426 legacy_vercel_project_id: None,
427 };
428
429 assert!(
430 resolve_push_credential(&config, Some(&repo), Some("https://github.com/a/b.git"))
431 .await
432 .is_none()
433 );
434 }
435
436 fn identity(login: &str, email: &str) -> GithubIdentityDef {
437 GithubIdentityDef {
438 host: "github.com".into(),
439 login: login.into(),
440 name: "Octo Cat".into(),
441 email: email.into(),
442 }
443 }
444}