pub mod git_email;
pub mod os_user;
pub mod provider_account;
use std::collections::VecDeque;
use std::sync::{LazyLock, Mutex};
pub use os_user::os_user;
const CACHE_CAP: usize = 64;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IdentitySignals {
pub git_email: Option<String>,
pub provider_account: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SessionIdentity {
Pending,
Ready(IdentitySignals),
}
type IdentityCache = Mutex<VecDeque<(String, SessionIdentity)>>;
static CACHE: LazyLock<IdentityCache> =
LazyLock::new(|| Mutex::new(VecDeque::with_capacity(CACHE_CAP)));
#[cfg(test)]
static RESOLVE_TASK_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
pub(crate) static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[cfg(test)]
pub(crate) mod test_support {
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
const MANAGED: [&str; 14] = [
"OPENLATCH_TEST_OS_USER",
"SUDO_USER",
"USER",
"LOGNAME",
"OPENLATCH_TEST_GIT_BIN",
"GIT_CONFIG_COUNT",
"GIT_CONFIG_KEY_0",
"GIT_CONFIG_VALUE_0",
"GIT_TEST_ASSUME_DIFFERENT_OWNER",
"OPENLATCH_TEST_CLAUDE_STATE_FILE",
"OPENLATCH_TEST_CLAUDE_SETTINGS_FILE",
"CLAUDE_CONFIG_DIR",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
];
pub(crate) struct EnvGuard {
saved: Vec<(&'static str, Option<OsString>)>,
}
impl EnvGuard {
pub(crate) fn clear() -> Self {
let saved = MANAGED
.iter()
.map(|key| (*key, std::env::var_os(key)))
.collect();
for key in MANAGED {
std::env::remove_var(key);
}
Self { saved }
}
pub(crate) fn set(&self, key: &str, value: impl AsRef<OsStr>) {
std::env::set_var(key, value);
}
pub(crate) fn unset(&self, key: &str) {
std::env::remove_var(key);
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (key, value) in &self.saved {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
}
pub(crate) fn git_ok(git: &Path, dir: &Path, args: &[&str]) -> bool {
std::process::Command::new(git)
.arg("-C")
.arg(dir)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
pub(crate) fn write_shim(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, body).expect("write shim");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("chmod shim");
}
path
}
}
pub fn observe_session(session_id: &str, cwd: Option<&str>) -> IdentitySignals {
let needs_resolution = {
let Ok(mut cache) = CACHE.lock() else {
return IdentitySignals::default();
};
match cache.iter().position(|(id, _)| id == session_id) {
Some(idx) => match &cache[idx].1 {
SessionIdentity::Ready(signals) => return signals.clone(),
SessionIdentity::Pending => false,
},
None => {
insert_capped(&mut cache, session_id, SessionIdentity::Pending);
true
}
}
};
if needs_resolution {
tracing::debug!(
target: "identity",
session_id = %session_id,
"resolving identity signals for a new session"
);
tokio::spawn(resolve_task(session_id.to_owned(), cwd.map(str::to_owned)));
}
IdentitySignals::default()
}
fn insert_capped(
cache: &mut VecDeque<(String, SessionIdentity)>,
session_id: &str,
state: SessionIdentity,
) {
if cache.len() >= CACHE_CAP {
cache.pop_front();
}
cache.push_back((session_id.to_owned(), state));
}
async fn resolve_task(session_id: String, cwd: Option<String>) {
#[cfg(test)]
RESOLVE_TASK_RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let (git_email, provider_account) = tokio::join!(
git_email::resolve(cwd.as_deref()),
provider_account::resolve()
);
store_ready(
&session_id,
IdentitySignals {
git_email,
provider_account,
},
);
}
fn store_ready(session_id: &str, signals: IdentitySignals) {
let Ok(mut cache) = CACHE.lock() else {
return;
};
match cache.iter().position(|(id, _)| id == session_id) {
Some(idx) => cache[idx].1 = SessionIdentity::Ready(signals),
None => insert_capped(&mut cache, session_id, SessionIdentity::Ready(signals)),
}
}
#[cfg(test)]
mod tests {
use super::test_support::{git_ok, write_shim, EnvGuard};
use super::*;
use std::sync::atomic::Ordering;
fn state_of(session_id: &str) -> Option<SessionIdentity> {
let cache = CACHE.lock().expect("cache lock");
cache
.iter()
.find(|(id, _)| id == session_id)
.map(|(_, state)| state.clone())
}
async fn settle(session_id: &str) {
for _ in 0..600 {
if matches!(state_of(session_id), Some(SessionIdentity::Ready(_))) {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
#[tokio::test]
async fn cache_serves_pending_then_ready_and_evicts_fifo() {
let _env = ENV_LOCK.lock().await;
let runs_before = RESOLVE_TASK_RUNS.load(Ordering::Relaxed);
let first_session = "sess_identity_first";
assert_eq!(
observe_session(first_session, Some("/repo")),
IdentitySignals::default()
);
assert_eq!(state_of(first_session), Some(SessionIdentity::Pending));
assert_eq!(
observe_session(first_session, Some("/repo")),
IdentitySignals::default()
);
settle(first_session).await;
assert_eq!(
RESOLVE_TASK_RUNS.load(Ordering::Relaxed) - runs_before,
1,
"one resolution per session, however many events arrive"
);
assert!(
matches!(state_of(first_session), Some(SessionIdentity::Ready(_))),
"the finished resolution is published back into the cache"
);
let ready_session = "sess_identity_ready";
let resolved = IdentitySignals {
git_email: Some("dev@example.com".to_string()),
provider_account: Some("dev@anthropic.example".to_string()),
};
store_ready(ready_session, resolved.clone());
let runs_before_ready = RESOLVE_TASK_RUNS.load(Ordering::Relaxed);
assert_eq!(observe_session(ready_session, None), resolved);
assert_eq!(
RESOLVE_TASK_RUNS.load(Ordering::Relaxed),
runs_before_ready,
"a cache hit resolves nothing"
);
for n in 0..CACHE_CAP {
observe_session(&format!("sess_identity_filler_{n}"), None);
}
assert_eq!(
state_of(first_session),
None,
"the oldest session is evicted once the ring is full"
);
assert_eq!(
CACHE.lock().expect("cache lock").len(),
CACHE_CAP,
"the ring never grows past its cap"
);
}
fn counting_shim(
dir: &std::path::Path,
real_git: &std::path::Path,
log: &std::path::Path,
) -> std::path::PathBuf {
let (name, body) = if cfg!(windows) {
(
"counting-git.cmd",
format!(
"@echo off\r\n>>\"{log}\" echo %*\r\n\"{git}\" %*\r\n",
log = log.display(),
git = real_git.display()
),
)
} else {
(
"counting-git.sh",
format!(
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"{log}\"\nexec \"{git}\" \"$@\"\n",
log = log.display(),
git = real_git.display()
),
)
};
write_shim(dir, name, &body)
}
fn git_calls(log: &std::path::Path, cwd: &str) -> usize {
std::fs::read_to_string(log)
.unwrap_or_default()
.lines()
.filter(|line| line.contains(cwd))
.count()
}
#[tokio::test]
async fn identity_memoisation_once_per_session() {
let _env = ENV_LOCK.lock().await;
let Some(real_git) = git_email::discover_platform() else {
return;
};
let workspace = tempfile::tempdir().expect("tempdir");
let repo = workspace.path().join("repo");
std::fs::create_dir(&repo).expect("repo dir");
if !git_ok(&real_git, &repo, &["init", "-q"]) {
return;
}
assert!(
git_ok(
&real_git,
&repo,
&["config", "user.email", "memo@fixture.test"]
),
"seed the repo-local address"
);
let log = workspace.path().join("git-invocations.log");
let shim = counting_shim(workspace.path(), &real_git, &log);
let state = workspace.path().join("state.json");
std::fs::write(
&state,
r#"{"oauthAccount":{"emailAddress":"alice@fixture.test"}}"#,
)
.expect("write state fixture");
let env = EnvGuard::clear();
env.set("OPENLATCH_TEST_GIT_BIN", &shim);
env.set("OPENLATCH_TEST_CLAUDE_STATE_FILE", &state);
let cwd = repo.to_str().expect("utf-8 path");
let runs_before = RESOLVE_TASK_RUNS.load(Ordering::Relaxed);
let first = "sess_identity_memo_first";
for _ in 0..5 {
observe_session(first, Some(cwd));
}
settle(first).await;
let per_session = git_calls(&log, cwd);
assert!(
per_session > 0,
"the resolution really did reach git — otherwise nothing below is measuring anything"
);
assert_eq!(
observe_session(first, Some(cwd)),
IdentitySignals {
git_email: Some("memo@fixture.test".to_string()),
provider_account: Some("alice@fixture.test".to_string()),
}
);
assert_eq!(
RESOLVE_TASK_RUNS.load(Ordering::Relaxed) - runs_before,
1,
"six events, one resolution"
);
for _ in 0..5 {
observe_session(first, Some(cwd));
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_eq!(
git_calls(&log, cwd),
per_session,
"a resolved session costs nothing further, however many events arrive"
);
let second = "sess_identity_memo_second";
observe_session(second, Some(cwd));
settle(second).await;
assert_eq!(
RESOLVE_TASK_RUNS.load(Ordering::Relaxed) - runs_before,
2,
"a new session resolves once more"
);
assert_eq!(
git_calls(&log, cwd) - per_session,
per_session,
"the second session pays the same fixed per-session cost as the first"
);
}
}