Set GIT_AUTHOR_*/GIT_COMMITTER_* from Settings → Account → Git (also inside the sandbox), so a repository's own user.name/user.email no longer wins; tell agents not to change the git identity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
6.6 KiB
Rust
170 lines
6.6 KiB
Rust
//! Per-user git credentials and commit identity.
|
|
//!
|
|
//! Stored in the database and rendered into the user's home directory
|
|
//! (`/data/users/<name>/home`), which is the home of their sandboxed agents,
|
|
//! terminals and git client: `.git-credentials` (credential store) and
|
|
//! `.gitconfig-codecraft`, included from `.gitconfig` so the user's own git
|
|
//! configuration stays untouched.
|
|
|
|
use crate::app::App;
|
|
use crate::store::User;
|
|
use anyhow::Result;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct GitCredential {
|
|
/// Host, optionally with a path prefix (e.g. "git.example.com" or "github.com/org").
|
|
pub host: String,
|
|
pub username: String,
|
|
/// Password or API token. Empty in updates = keep the stored one.
|
|
#[serde(default)]
|
|
pub token: String,
|
|
/// "https" (default) or "http".
|
|
#[serde(default)]
|
|
pub protocol: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct GitSettings {
|
|
#[serde(default)]
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub email: String,
|
|
#[serde(default)]
|
|
pub credentials: Vec<GitCredential>,
|
|
}
|
|
|
|
/// The user's commit identity as environment variables. They take precedence over a
|
|
/// repository's own `user.name`/`user.email`, so commits made by agents, terminals and
|
|
/// the git panel always carry the identity from the settings.
|
|
pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
|
|
let s: GitSettings = app.store.user_setting(user_id, "git").unwrap_or_default();
|
|
let mut env = Vec::new();
|
|
let (name, email) = (s.name.trim(), s.email.trim());
|
|
if !name.is_empty() {
|
|
env.push(("GIT_AUTHOR_NAME".to_string(), name.to_string()));
|
|
env.push(("GIT_COMMITTER_NAME".to_string(), name.to_string()));
|
|
}
|
|
if !email.is_empty() {
|
|
env.push(("GIT_AUTHOR_EMAIL".to_string(), email.to_string()));
|
|
env.push(("GIT_COMMITTER_EMAIL".to_string(), email.to_string()));
|
|
}
|
|
env
|
|
}
|
|
|
|
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
|
app.data_dir().join("users").join(&user.username).join("home")
|
|
}
|
|
|
|
/// Global git config for processes that run outside the sandbox (admins).
|
|
pub fn global_config(app: &App, user: &User) -> Option<PathBuf> {
|
|
let p = home_of(app, user).join(".gitconfig");
|
|
p.exists().then_some(p)
|
|
}
|
|
|
|
fn enc(s: &str) -> String {
|
|
let mut out = String::new();
|
|
for b in s.bytes() {
|
|
match b {
|
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
|
|
_ => out.push_str(&format!("%{b:02X}")),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn quote(s: &str) -> String {
|
|
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
|
}
|
|
|
|
/// Write the credential store and config into the user's home.
|
|
pub fn write_files(app: &App, user: &User, settings: &GitSettings) -> Result<()> {
|
|
let home = home_of(app, user);
|
|
std::fs::create_dir_all(&home)?;
|
|
let creds_path = home.join(".git-credentials");
|
|
let mut creds = String::new();
|
|
for c in &settings.credentials {
|
|
if c.host.trim().is_empty() || c.token.is_empty() {
|
|
continue;
|
|
}
|
|
let proto = if c.protocol == "http" { "http" } else { "https" };
|
|
let (host, path) = match c.host.trim().trim_start_matches("https://").trim_start_matches("http://").split_once('/') {
|
|
Some((h, p)) => (h.to_string(), format!("/{}", p.trim_matches('/'))),
|
|
None => (c.host.trim().trim_start_matches("https://").trim_start_matches("http://").to_string(), String::new()),
|
|
};
|
|
creds.push_str(&format!("{proto}://{}:{}@{host}{path}\n", enc(c.username.trim()), enc(&c.token)));
|
|
}
|
|
std::fs::write(&creds_path, creds)?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&creds_path, std::fs::Permissions::from_mode(0o600))?;
|
|
}
|
|
|
|
// Managed part of the config. Two helpers: the absolute path works outside
|
|
// the sandbox, the default (~/.git-credentials) inside it.
|
|
let mut cfg = String::from("# Managed by CodeCraft (Settings → Account → Git). Do not edit.\n");
|
|
if !settings.name.trim().is_empty() || !settings.email.trim().is_empty() {
|
|
cfg.push_str("[user]\n");
|
|
if !settings.name.trim().is_empty() {
|
|
cfg.push_str(&format!("\tname = {}\n", quote(settings.name.trim())));
|
|
}
|
|
if !settings.email.trim().is_empty() {
|
|
cfg.push_str(&format!("\temail = {}\n", quote(settings.email.trim())));
|
|
}
|
|
}
|
|
cfg.push_str(&format!("[credential]\n\thelper = {}\n\thelper = store\n", quote(&format!("store --file={}", creds_path.display()))));
|
|
// Matching credentials by path needs useHttpPath for entries with a path.
|
|
if settings.credentials.iter().any(|c| c.host.trim().trim_start_matches("https://").contains('/')) {
|
|
cfg.push_str("\tuseHttpPath = true\n");
|
|
}
|
|
std::fs::write(home.join(".gitconfig-codecraft"), cfg)?;
|
|
|
|
let main = home.join(".gitconfig");
|
|
let current = std::fs::read_to_string(&main).unwrap_or_default();
|
|
if !current.contains(".gitconfig-codecraft") {
|
|
let mut next = current;
|
|
if !next.is_empty() && !next.ends_with('\n') {
|
|
next.push('\n');
|
|
}
|
|
next.push_str("[include]\n\tpath = .gitconfig-codecraft\n");
|
|
std::fs::write(&main, next)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Settings with tokens masked for the UI.
|
|
pub fn masked(settings: &GitSettings) -> GitSettings {
|
|
let mut s = settings.clone();
|
|
for c in &mut s.credentials {
|
|
c.token = if c.token.is_empty() { String::new() } else { "••••••••".into() };
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Merge an update from the UI: empty or masked tokens keep the stored token.
|
|
pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings {
|
|
for c in &mut new.credentials {
|
|
if c.token.is_empty() || c.token.chars().all(|ch| ch == '•') {
|
|
if let Some(prev) = old.credentials.iter().find(|p| p.host == c.host && p.username == c.username) {
|
|
c.token = prev.token.clone();
|
|
}
|
|
}
|
|
}
|
|
new
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn keeps_masked_tokens() {
|
|
let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() };
|
|
let upd = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "••••••••".into(), protocol: String::new() }], ..Default::default() };
|
|
assert_eq!(merge(&old, upd).credentials[0].token, "secret");
|
|
assert_eq!(enc("a@b:c/d"), "a%40b%3Ac%2Fd");
|
|
}
|
|
}
|