diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0d14c244..bb95d1ca 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -41,6 +41,20 @@ export GIT_COMMITTER_NAME="$GIT_USER_NAME" export GIT_AUTHOR_EMAIL="$GIT_USER_EMAIL" export GIT_COMMITTER_EMAIL="$GIT_USER_EMAIL" +# ── Git credential helper (HTTPS push) ──────────────────────────────────── +# If GIT_PUSH_TOKEN is supplied at container creation time, configure git's +# built-in credential store so `git push` over HTTPS authenticates without +# user interaction. GIT_CLONE_URL provides the host portion of the URL used +# as the key in ~/.git-credentials. +if [ -n "$GIT_PUSH_TOKEN" ] && [ -n "$GIT_CLONE_URL" ]; then + _scheme=$(echo "$GIT_CLONE_URL" | cut -d':' -f1) + _host=$(echo "$GIT_CLONE_URL" | sed 's|^https\?://||' | cut -d'/' -f1) + git config --global credential.helper store + printf '%s://x-access-token:%s@%s\n' "$_scheme" "$GIT_PUSH_TOKEN" "$_host" \ + > /home/huskies/.git-credentials + chmod 600 /home/huskies/.git-credentials +fi + # ── Frontend native deps ──────────────────────────────────────────── # The project repo is bind-mounted from the host, so node_modules/ # may contain native binaries for the wrong platform (e.g. darwin diff --git a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs index 9892869c..69918fcc 100644 --- a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs @@ -270,14 +270,17 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( ) { slog!( - "[matrix-bot] Handling new project command from {sender}: name={:?} stack={:?}", + "[matrix-bot] Handling new project command from {sender}: name={:?} stack={:?} git_url={:?}", cmd.name, - cmd.stack + cmd.stack, + cmd.git_url, ); let response = if let Some(ref store) = ctx.gateway_projects_store { super::super::super::new_project::handle_new_project( &cmd.name, cmd.stack.as_deref(), + cmd.git_url.as_deref(), + cmd.git_token.as_deref(), store, &ctx.services.project_root, ) diff --git a/server/src/chat/transport/matrix/config/types.rs b/server/src/chat/transport/matrix/config/types.rs index 1e291e6a..f9c375ae 100644 --- a/server/src/chat/transport/matrix/config/types.rs +++ b/server/src/chat/transport/matrix/config/types.rs @@ -202,4 +202,20 @@ pub struct BotConfig { /// Defaults to 1 500 ms (1.5 s). #[serde(default = "default_coalesce_window_ms")] pub coalesce_window_ms: u64, + + /// Git `user.name` to inject into project containers created by `new project`. + /// + /// Passed as `GIT_USER_NAME` to the container entrypoint so agents can commit + /// code with the correct author identity. Falls back to the host's + /// `git config user.name` when absent. + #[serde(default)] + pub git_user_name: Option, + + /// Git `user.email` to inject into project containers created by `new project`. + /// + /// Passed as `GIT_USER_EMAIL` to the container entrypoint so agents can commit + /// code with the correct author identity. Falls back to the host's + /// `git config user.email` when absent. + #[serde(default)] + pub git_user_email: Option, } diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs index 51b8d8a2..fe66728d 100644 --- a/server/src/chat/transport/matrix/new_project.rs +++ b/server/src/chat/transport/matrix/new_project.rs @@ -1,7 +1,8 @@ -//! `new project ` chat command — Phase 3: SSH-remote editor access. +//! `new project ` chat command — Phase 4: `--git` clone and push credentials. //! //! Provisions a project container and registers it with the gateway. -//! The command is gateway-only: `new project [--stack ]`. +//! The command is gateway-only: +//! `new project [--stack ] [--git ] [--git-token ]` //! //! Without `--stack`, the orchestrator inspects the (just-cloned or //! just-init'd) source tree for stack markers found in @@ -19,6 +20,21 @@ //! is bound to a host-local port in the 2200–2300 range and recorded in //! `projects.toml` as `ssh_port`. //! +//! Phase 4 (story 1109): +//! - `--git ` clones the URL into the host project directory instead of +//! running `git init`. Clone failure aborts the bootstrap with no partial +//! state left on disk. +//! - The container receives `GIT_USER_NAME` and `GIT_USER_EMAIL` env vars drawn +//! from `git_user_name`/`git_user_email` in the gateway's `bot.toml`, with +//! fallback to the host's `git config user.name`/`user.email`. +//! - The host user's SSH keys (`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`) are +//! bind-mounted read-only into the container so `git push` over SSH works. +//! - `--git-token ` stores the HTTPS push token in the container's +//! git credential store via the entrypoint; the token is never echoed in +//! chat or logs. +//! - After the container starts, `git ls-remote` verifies push credentials and +//! surfaces success or an actionable failure message in the chat reply. +//! //! Adding a new stack requires only: //! 1. `docker/stacks//Dockerfile.fragment` — overlay instructions //! 2. `docker/stacks//markers` — detection marker filenames @@ -31,15 +47,25 @@ use tokio::sync::RwLock; use crate::service::gateway::config::ProjectEntry; -/// Parsed result of a `new project [--stack ]` chat command. +/// Parsed result of a `new project [--stack ] [--git ] [--git-token ]` command. pub struct NewProjectCommand { /// Project name (alphanumeric, hyphens, underscores). pub name: String, /// Explicitly requested stack, or `None` for auto-detection. pub stack: Option, + /// Git repository URL to clone into the project directory instead of running `git init`. + /// + /// When `Some`, the bootstrap runs `git clone ` instead of `git init`. + /// Failure aborts the whole bootstrap with no partial state left on disk. + pub git_url: Option, + /// HTTPS push token for the repository. + /// + /// Stored in the container's git credential helper by the entrypoint. + /// **Never echoed in any chat reply or log line.** + pub git_token: Option, } -/// Parse a `new project [--stack ]` command from a chat message. +/// Parse a `new project [--stack ] [--git ] [--git-token ]` command. /// /// Returns `Some(NewProjectCommand)` when the stripped message starts with /// "new project" (case-insensitive). An empty name (bare "new project" with @@ -67,18 +93,24 @@ pub fn extract_new_project_command( } let name = words.next().unwrap_or("").to_string(); - // Scan remaining tokens for `--stack `. let remaining: Vec<&str> = words.collect(); - let stack = parse_stack_flag(&remaining); + let stack = parse_flag(&remaining, "--stack"); + let git_url = parse_flag(&remaining, "--git"); + let git_token = parse_flag(&remaining, "--git-token"); - Some(NewProjectCommand { name, stack }) + Some(NewProjectCommand { + name, + stack, + git_url, + git_token, + }) } -/// Extract the value of `--stack ` from a token slice. -fn parse_stack_flag(tokens: &[&str]) -> Option { +/// Extract the value of `-- ` from a token slice. +fn parse_flag(tokens: &[&str], flag: &str) -> Option { let mut iter = tokens.iter().peekable(); while let Some(tok) = iter.next() { - if *tok == "--stack" + if *tok == flag && let Some(val) = iter.next() { return Some(val.to_string()); @@ -164,6 +196,136 @@ pub fn image_for_stack(stack: Option<&str>) -> String { } } +/// Read `git_user_name` and `git_user_email` from the gateway's `bot.toml`. +/// +/// Deserialises directly into `BotConfig` without the enabled/transport +/// validation that `BotConfig::load` enforces, so the identity fields are +/// always available even when the bot transport is not yet configured. +async fn read_git_identity_from_bot_toml( + config_dir: &std::path::Path, +) -> (Option, Option) { + use crate::chat::transport::matrix::BotConfig; + let path = config_dir.join(".huskies").join("bot.toml"); + let Ok(content) = tokio::fs::read_to_string(&path).await else { + return (None, None); + }; + let Ok(cfg) = toml::from_str::(&content) else { + return (None, None); + }; + let name = cfg.git_user_name.filter(|s: &String| !s.is_empty()); + let email = cfg.git_user_email.filter(|s: &String| !s.is_empty()); + (name, email) +} + +/// Read a single key from the host's global git config. +async fn read_host_git_config(key: &str) -> Option { + let out = tokio::process::Command::new("git") + .args(["config", "--global", key]) + .output() + .await + .ok()?; + if out.status.success() { + let val = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if val.is_empty() { None } else { Some(val) } + } else { + None + } +} + +/// Resolve the git identity to use for new project containers. +/// +/// Priority: `bot.toml` fields → host `git config` → hardcoded fallback. +async fn resolve_git_identity(config_dir: &std::path::Path) -> (String, String) { + let (bot_name, bot_email) = read_git_identity_from_bot_toml(config_dir).await; + + let name = if let Some(n) = bot_name { + n + } else if let Some(n) = read_host_git_config("user.name").await { + n + } else { + "Huskies Agent".to_string() + }; + + let email = if let Some(e) = bot_email { + e + } else if let Some(e) = read_host_git_config("user.email").await { + e + } else { + "agent@huskies.local".to_string() + }; + + (name, email) +} + +/// Inject a token into an HTTPS git URL for credential-passing. +/// +/// `https://github.com/user/repo` → `https://x-access-token:@github.com/user/repo` +/// The returned URL must NEVER be included in user-visible replies or logs. +fn inject_token_into_url(url: &str, token: &str) -> String { + if let Some(rest) = url.strip_prefix("https://") { + format!("https://x-access-token:{token}@{rest}") + } else if let Some(rest) = url.strip_prefix("http://") { + format!("http://x-access-token:{token}@{rest}") + } else { + url.to_string() + } +} + +/// Verify push credentials by running `git ls-remote` against the repository. +/// +/// Returns `Ok(message)` on success or `Err(actionable_message)` on failure. +/// The token (if any) is never included in the returned strings. +async fn verify_push_credentials(git_url: &str, git_token: Option<&str>) -> Result { + let url_for_cmd = match git_token { + Some(token) => inject_token_into_url(git_url, token), + None => git_url.to_string(), + }; + + let mut cmd = tokio::process::Command::new("git"); + cmd.arg("ls-remote").arg(&url_for_cmd); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + if git_token.is_none() { + // SSH: accept new host keys non-interactively; fail fast if no agent. + cmd.env( + "GIT_SSH_COMMAND", + "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", + ); + } + + let output = cmd + .output() + .await + .map_err(|e| format!("git ls-remote unavailable: {e}"))?; + + if output.status.success() { + return Ok("Push credentials verified.".to_string()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + // Map common failure patterns to actionable messages (no token in output). + if stderr.contains("Permission denied") || stderr.contains("publickey") { + Err( + "SSH key not authorised by remote — check that your public key is \ + added to the repository's deploy keys or your account." + .to_string(), + ) + } else if stderr.contains("401") + || stderr.contains("403") + || stderr.contains("Authentication failed") + || stderr.contains("could not read Username") + { + Err( + "Token rejected — verify that the token has read/write access \ + to the repository." + .to_string(), + ) + } else { + Err(format!( + "Push verification failed for `{git_url}`: {stderr}" + )) + } +} + /// Generate an ed25519 SSH keypair at `key_path` (private) and `key_path.pub` (public). /// /// Calls `ssh-keygen -t ed25519 -N "" -f ` with no passphrase. @@ -191,18 +353,22 @@ async fn generate_ssh_keypair(key_path: &std::path::Path) -> Result [--stack ]` command. +/// Bootstrap a new project from the `new project` chat command. /// -/// Creates `~/huskies//`, scaffolds `.huskies/`, runs `git init`, -/// auto-detects or honours the requested stack, generates an SSH keypair, -/// launches the appropriate Docker container, and registers the project in -/// both the gateway's in-memory store and the CRDT. +/// Creates `~/huskies//`, scaffolds `.huskies/`, runs `git clone` (when +/// `git_url` is provided) or `git init`, auto-detects or honours the requested +/// stack, generates an SSH keypair, launches the appropriate Docker container, +/// and registers the project in the gateway's in-memory store and the CRDT. /// -/// On any failure after the host directory is created, the directory is removed -/// and the error message includes "Partial state removed at ``". +/// On any failure after a directory is created, the directory is removed and +/// the error message includes "Partial state removed at ``". +/// +/// `git_token` is never echoed in any returned string or log line. pub async fn handle_new_project( name: &str, stack: Option<&str>, + git_url: Option<&str>, + git_token: Option<&str>, projects_store: &Arc>>, config_dir: &Path, ) -> String { @@ -234,7 +400,7 @@ pub async fn handle_new_project( // Default host path: ~/huskies// let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string()); - let host_path = std::path::PathBuf::from(home).join("huskies").join(name); + let host_path = std::path::PathBuf::from(&home).join("huskies").join(name); if host_path.exists() { return format!( @@ -244,48 +410,97 @@ pub async fn handle_new_project( ); } - // ── Create host directory ──────────────────────────────────────────────── + // ── git clone or init + scaffold ───────────────────────────────────────── + // + // For `--git `: git clone creates host_path itself, so we must NOT call + // ensure_directory on it beforehand. Scaffold runs after a successful clone. + // + // For no `--git`: create host_path first, scaffold, then git init (unchanged + // from Phase 3 behaviour). - if let Err(e) = crate::service::gateway::io::ensure_directory(&host_path) { - return format!("Failed to create `{}`: {e}", host_path.display()); - } + if let Some(url) = git_url { + // Ensure the parent directory (~/.huskies/) exists but not host_path itself. + if let Some(parent) = host_path.parent() + && let Err(e) = crate::service::gateway::io::ensure_directory(parent) + { + return format!( + "Failed to create parent directory `{}`: {e}", + parent.display() + ); + } - // ── Scaffold .huskies/ ─────────────────────────────────────────────────── + let clone_out = tokio::process::Command::new("git") + .arg("clone") + .arg(url) + .arg(&host_path) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .await; - if let Err(e) = crate::service::gateway::io::scaffold_project(&host_path) { - let _ = tokio::fs::remove_dir_all(&host_path).await; - return format!( - "Scaffold failed: {e}\n\nPartial state removed at `{}`.", - host_path.display() - ); - } - crate::service::gateway::io::init_wizard_state(&host_path); + match clone_out { + Err(e) => { + return format!("git clone failed: {e}"); + } + Ok(out) if !out.status.success() => { + let stderr = String::from_utf8_lossy(&out.stderr); + let _ = tokio::fs::remove_dir_all(&host_path).await; + return format!( + "git clone failed: {}\n\nPartial state removed at `{}`.", + stderr.trim(), + host_path.display() + ); + } + Ok(_) => {} + } - // ── git init ───────────────────────────────────────────────────────────── - - match tokio::process::Command::new("git") - .arg("init") - .arg(&host_path) - .output() - .await - { - Err(e) => { + // Scaffold .huskies/ into the cloned repo (write_file_if_missing — safe on existing repos). + if let Err(e) = crate::service::gateway::io::scaffold_project(&host_path) { let _ = tokio::fs::remove_dir_all(&host_path).await; return format!( - "git init failed: {e}\n\nPartial state removed at `{}`.", + "Scaffold failed: {e}\n\nPartial state removed at `{}`.", host_path.display() ); } - Ok(out) if !out.status.success() => { - let stderr = String::from_utf8_lossy(&out.stderr); + crate::service::gateway::io::init_wizard_state(&host_path); + } else { + // No --git: create directory, scaffold, then git init. + if let Err(e) = crate::service::gateway::io::ensure_directory(&host_path) { + return format!("Failed to create `{}`: {e}", host_path.display()); + } + + if let Err(e) = crate::service::gateway::io::scaffold_project(&host_path) { let _ = tokio::fs::remove_dir_all(&host_path).await; return format!( - "git init failed: {}\n\nPartial state removed at `{}`.", - stderr.trim(), + "Scaffold failed: {e}\n\nPartial state removed at `{}`.", host_path.display() ); } - Ok(_) => {} + crate::service::gateway::io::init_wizard_state(&host_path); + + match tokio::process::Command::new("git") + .arg("init") + .arg(&host_path) + .output() + .await + { + Err(e) => { + let _ = tokio::fs::remove_dir_all(&host_path).await; + return format!( + "git init failed: {e}\n\nPartial state removed at `{}`.", + host_path.display() + ); + } + Ok(out) if !out.status.success() => { + let stderr = String::from_utf8_lossy(&out.stderr); + let _ = tokio::fs::remove_dir_all(&host_path).await; + return format!( + "git init failed: {}\n\nPartial state removed at `{}`.", + stderr.trim(), + host_path.display() + ); + } + Ok(_) => {} + } } // ── Detect or validate stack ───────────────────────────────────────────── @@ -301,7 +516,6 @@ pub async fn handle_new_project( // Private key: ~/.huskies//id_ed25519 (host-side, mode 600 by ssh-keygen) // Public key: installed in the container via HUSKIES_SSH_PUBKEY env var - let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string()); let ssh_key_dir = std::path::PathBuf::from(&home).join(".huskies").join(name); if let Err(e) = tokio::fs::create_dir_all(&ssh_key_dir).await { let _ = tokio::fs::remove_dir_all(&host_path).await; @@ -324,6 +538,27 @@ pub async fn handle_new_project( } }; + // ── Resolve git identity ───────────────────────────────────────────────── + // Read from bot.toml → fallback to host git config → hardcoded default. + + let (git_user_name, git_user_email) = resolve_git_identity(config_dir).await; + + // ── Discover host SSH keys for bind-mounting ───────────────────────────── + // The user's personal SSH keys are mounted read-only so `git push` over SSH + // works inside the container without copying secrets into the image. + + let host_ssh_dir = std::path::PathBuf::from(&home).join(".ssh"); + let mut ssh_key_mounts: Vec = Vec::new(); + for key_name in &["id_ed25519", "id_rsa"] { + let key_path = host_ssh_dir.join(key_name); + if key_path.exists() { + ssh_key_mounts.push(format!( + "{}:/home/huskies/.ssh/{key_name}:ro", + key_path.display() + )); + } + } + // ── Allocate ports and launch container ────────────────────────────────── let port = find_free_port(3100); @@ -331,26 +566,53 @@ pub async fn handle_new_project( let container_url = format!("http://127.0.0.1:{port}"); let container_name = format!("huskies-{name}"); + // Build the `docker run` argument list. The token must never appear in any + // string that is returned to the caller or written to a log. + let mut docker_args: Vec = vec![ + "run".into(), + "-d".into(), + "--name".into(), + container_name.clone(), + "-p".into(), + format!("127.0.0.1:{port}:3001"), + "-p".into(), + format!("127.0.0.1:{ssh_port}:22"), + "-e".into(), + format!("HUSKIES_SSH_PUBKEY={pubkey}"), + "-e".into(), + format!("GIT_USER_NAME={git_user_name}"), + "-e".into(), + format!("GIT_USER_EMAIL={git_user_email}"), + ]; + + // HTTPS push token: passed as env vars consumed by the entrypoint credential helper. + if let Some(token) = git_token { + docker_args.push("-e".into()); + docker_args.push(format!("GIT_PUSH_TOKEN={token}")); + if let Some(url) = git_url { + docker_args.push("-e".into()); + docker_args.push(format!("GIT_CLONE_URL={url}")); + } + } + + // Workspace mount. + docker_args.push("-v".into()); + docker_args.push(format!("{}:/workspace", host_path.display())); + + // SSH key bind-mounts (read-only). + for mount in &ssh_key_mounts { + docker_args.push("-v".into()); + docker_args.push(mount.clone()); + } + + docker_args.push("--restart".into()); + docker_args.push("unless-stopped".into()); + docker_args.push(image.clone()); + docker_args.push("huskies".into()); + docker_args.push("/workspace".into()); + let docker_result = tokio::process::Command::new("docker") - .args([ - "run", - "-d", - "--name", - &container_name, - "-p", - &format!("127.0.0.1:{port}:3001"), - "-p", - &format!("127.0.0.1:{ssh_port}:22"), - "-e", - &format!("HUSKIES_SSH_PUBKEY={pubkey}"), - "-v", - &format!("{}:/workspace", host_path.display()), - "--restart", - "unless-stopped", - &image, - "huskies", - "/workspace", - ]) + .args(&docker_args) .output() .await; @@ -378,6 +640,18 @@ pub async fn handle_new_project( ssh=127.0.0.1:{ssh_port} (image={image})" ); + // ── Push credential verification ───────────────────────────────── + // Only run when a git URL was provided (clone path); skip for plain + // git init projects where there is no remote to verify. + let push_note = if let Some(url) = git_url { + match verify_push_credentials(url, git_token).await { + Ok(msg) => format!("- Push credentials: {msg}\n"), + Err(err) => format!("> ⚠ Push verification: {err}\n"), + } + } else { + String::new() + }; + let stack_note = match resolved_stack.as_deref() { Some(s) => format!("- Stack: **{s}** (`{image}`)\n"), None => String::new(), @@ -393,6 +667,7 @@ pub async fn handle_new_project( - Host path: `{host}`\n\ - Container: `{container_name}` → `{container_url}`\n\ {stack_note}\ + {push_note}\ - SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \ -i ~/.huskies/{name}/id_ed25519`\n\ \n\ @@ -788,6 +1063,151 @@ mod tests { assert!(warnings.is_empty()); } + // ── Phase 4: --git and --git-token flag parsing ────────────────────────── + + #[test] + fn extract_parses_git_flag() { + let cmd = extract_new_project_command( + "@timmy new project myapp --git https://github.com/user/repo", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert_eq!(cmd.name, "myapp"); + assert_eq!( + cmd.git_url, + Some("https://github.com/user/repo".to_string()) + ); + assert_eq!(cmd.git_token, None); + } + + #[test] + fn extract_parses_git_token_flag() { + let cmd = extract_new_project_command( + "@timmy new project myapp --git https://github.com/user/repo --git-token ghp_secret", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert_eq!( + cmd.git_url, + Some("https://github.com/user/repo".to_string()) + ); + assert_eq!(cmd.git_token, Some("ghp_secret".to_string())); + } + + #[test] + fn extract_git_token_without_git_url() { + let cmd = extract_new_project_command( + "@timmy new project myapp --git-token ghp_secret", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert_eq!(cmd.git_url, None); + assert_eq!(cmd.git_token, Some("ghp_secret".to_string())); + } + + #[test] + fn extract_git_flag_with_stack() { + let cmd = extract_new_project_command( + "@timmy new project myapp --stack rust --git git@github.com:user/repo.git", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert_eq!(cmd.stack, Some("rust".to_string())); + assert_eq!( + cmd.git_url, + Some("git@github.com:user/repo.git".to_string()) + ); + } + + #[test] + fn extract_no_git_flag_returns_none_fields() { + let cmd = extract_new_project_command( + "@timmy new project myapp --stack node", + "Timmy", + "@timmy:srv.local", + ) + .unwrap(); + assert_eq!(cmd.git_url, None); + assert_eq!(cmd.git_token, None); + } + + #[test] + fn inject_token_into_https_url() { + let result = inject_token_into_url("https://github.com/user/repo", "mytoken"); + assert_eq!( + result, + "https://x-access-token:mytoken@github.com/user/repo" + ); + } + + #[test] + fn inject_token_into_http_url() { + let result = inject_token_into_url("http://gitea.local/user/repo", "tok123"); + assert_eq!(result, "http://x-access-token:tok123@gitea.local/user/repo"); + } + + #[test] + fn inject_token_into_ssh_url_passthrough() { + // SSH URLs are returned unchanged — token injection only applies to HTTPS. + let url = "git@github.com:user/repo.git"; + let result = inject_token_into_url(url, "token"); + assert_eq!(result, url); + } + + #[tokio::test] + async fn read_git_identity_from_bot_toml_reads_fields() { + let dir = tempfile::tempdir().unwrap(); + let huskies_dir = dir.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + std::fs::write( + huskies_dir.join("bot.toml"), + "enabled = true\ntransport = \"matrix\"\ngit_user_name = \"Test User\"\ngit_user_email = \"test@example.com\"\n", + ) + .unwrap(); + + let (name, email) = read_git_identity_from_bot_toml(dir.path()).await; + assert_eq!(name, Some("Test User".to_string())); + assert_eq!(email, Some("test@example.com".to_string())); + } + + #[tokio::test] + async fn read_git_identity_from_bot_toml_missing_file_returns_nones() { + let dir = tempfile::tempdir().unwrap(); + let (name, email) = read_git_identity_from_bot_toml(dir.path()).await; + assert_eq!(name, None); + assert_eq!(email, None); + } + + #[tokio::test] + async fn resolve_git_identity_uses_bot_toml_values() { + let dir = tempfile::tempdir().unwrap(); + let huskies_dir = dir.path().join(".huskies"); + std::fs::create_dir_all(&huskies_dir).unwrap(); + std::fs::write( + huskies_dir.join("bot.toml"), + "enabled = true\ntransport = \"matrix\"\ngit_user_name = \"Bot Name\"\ngit_user_email = \"bot@example.com\"\n", + ) + .unwrap(); + + let (name, email) = resolve_git_identity(dir.path()).await; + assert_eq!(name, "Bot Name"); + assert_eq!(email, "bot@example.com"); + } + + #[tokio::test] + async fn resolve_git_identity_falls_back_to_defaults_when_no_config() { + let dir = tempfile::tempdir().unwrap(); + // No bot.toml and git config may or may not have values on CI — we only + // check that the result is non-empty strings (not panics or errors). + let (name, email) = resolve_git_identity(dir.path()).await; + assert!(!name.is_empty(), "name must be non-empty"); + assert!(!email.is_empty(), "email must be non-empty"); + } + /// A polyglot repo with more Python markers than Node markers should prefer python. #[test] fn detect_stack_multiple_dominant_wins() {