Skip to main content

nomoreide_core/
git_identity.rs

1//! Commit identity derived from the GitHub account selected for a repository.
2//!
3//! Rust counterpart of `src/core/git-identity.ts`. The account switcher governs
4//! the GitHub API (pull requests, issues, CI) while plain `git commit` would
5//! otherwise use the machine's `user.email` — letting a commit be authored by
6//! one account and its pull request opened by another with nothing saying so.
7//!
8//! Scoped to the repository on purpose: `gh auth switch` would change identity
9//! for every terminal and every other repo on the machine.
10
11use 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/// What `git config user.*` resolves to in a working tree.
21#[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    /// Identity commits will carry, or None when the machine's config governs.
34    pub selected: Option<GithubIdentityDef>,
35    pub machine: MachineIdentity,
36    /// True when a commit here would carry an author the machine does not use.
37    pub diverged: bool,
38    /// Why `selected` is None — the UI explains the fallback rather than hiding it.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub reason: Option<String>,
41}
42
43/// Environment that pins a commit's author and committer. Passed per command
44/// rather than written to `git config`, so nothing about the machine changes.
45pub 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
54/// [`resolve_identity_state`] for callers that only know a working directory —
55/// the MCP tools and the CLI, which take a `cwd` rather than a repository name.
56/// An unregistered or ambiguous directory resolves to "no selection", which
57/// keeps the machine identity in charge.
58pub 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
64/// The registered repository owning `cwd`, or None when there is not a clear
65/// one. An ambiguous or nested directory is None here rather than an error:
66/// the caller falls back to the machine's identity, which is what git would
67/// have done anyway.
68pub 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
120/// Identity for the repository's selected account, from the config cache when
121/// possible and the GitHub API otherwise.
122pub 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
173/// Token to push the selected account with, or None to leave the machine's
174/// credential helper in charge.
175///
176/// None for SSH remotes on purpose: authentication there comes from a key, and
177/// no token can change which key `ssh` offers.
178pub 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
204/// Host of an HTTPS git remote, or None when the remote is SSH or unparseable.
205pub 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    // Strip any userinfo and port so the comparison is host-only.
212    let host = authority.rsplit('@').next()?.split(':').next()?;
213    if host.is_empty() {
214        None
215    } else {
216        Some(host.to_string())
217    }
218}
219
220/// The address a commit should carry. GitHub's own address is preferred over a
221/// guess; the id-qualified `noreply` form still attributes the commit when the
222/// account keeps its email private.
223pub 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    // `git config --get` exits non-zero when the key is unset — an unconfigured
296    // machine identity is a normal state here, not a failure.
297    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        // SSH remotes authenticate with a key, so no token can re-attribute them.
350        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        // An unconfigured machine identity counts as divergence — the commit
381        // would otherwise be authored by whatever git falls back to.
382        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}