huskies: merge 1109 story Chat bootstrap Phase 4: --git clones an existing repo and configures push credentials

This commit is contained in:
dave
2026-05-17 00:12:40 +00:00
parent 59302b465d
commit f8212f102f
4 changed files with 521 additions and 68 deletions
+14
View File
@@ -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
@@ -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,
)
@@ -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<String>,
/// 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<String>,
}
+486 -66
View File
@@ -1,7 +1,8 @@
//! `new project <name>` chat command — Phase 3: SSH-remote editor access.
//! `new project <name>` 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 <name> [--stack <stack>]`.
//! The command is gateway-only:
//! `new project <name> [--stack <stack>] [--git <url>] [--git-token <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 22002300 range and recorded in
//! `projects.toml` as `ssh_port`.
//!
//! Phase 4 (story 1109):
//! - `--git <url>` 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 <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/<name>/Dockerfile.fragment` — overlay instructions
//! 2. `docker/stacks/<name>/markers` — detection marker filenames
@@ -31,15 +47,25 @@ use tokio::sync::RwLock;
use crate::service::gateway::config::ProjectEntry;
/// Parsed result of a `new project <name> [--stack <stack>]` chat command.
/// Parsed result of a `new project <name> [--stack <s>] [--git <url>] [--git-token <tok>]` command.
pub struct NewProjectCommand {
/// Project name (alphanumeric, hyphens, underscores).
pub name: String,
/// Explicitly requested stack, or `None` for auto-detection.
pub stack: Option<String>,
/// Git repository URL to clone into the project directory instead of running `git init`.
///
/// When `Some`, the bootstrap runs `git clone <url>` instead of `git init`.
/// Failure aborts the whole bootstrap with no partial state left on disk.
pub git_url: Option<String>,
/// 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<String>,
}
/// Parse a `new project <name> [--stack <stack>]` command from a chat message.
/// Parse a `new project <name> [--stack <s>] [--git <url>] [--git-token <tok>]` 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 <value>`.
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 <value>` from a token slice.
fn parse_stack_flag(tokens: &[&str]) -> Option<String> {
/// Extract the value of `--<flag> <value>` from a token slice.
fn parse_flag(tokens: &[&str], flag: &str) -> Option<String> {
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<String>, Option<String>) {
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::<BotConfig>(&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<String> {
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:<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<String, String> {
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 <key_path>` with no passphrase.
@@ -191,18 +353,22 @@ async fn generate_ssh_keypair(key_path: &std::path::Path) -> Result<String, Stri
.map_err(|e| format!("Cannot read public key {}: {e}", pub_path.display()))
}
/// Bootstrap a new project from the `new project <name> [--stack <stack>]` command.
/// Bootstrap a new project from the `new project` chat command.
///
/// Creates `~/huskies/<name>/`, 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/<name>/`, 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 `<path>`".
/// On any failure after a directory is created, the directory is removed and
/// the error message includes "Partial state removed at `<path>`".
///
/// `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<RwLock<BTreeMap<String, ProjectEntry>>>,
config_dir: &Path,
) -> String {
@@ -234,7 +400,7 @@ pub async fn handle_new_project(
// Default host path: ~/huskies/<name>/
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 <url>`: 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/<name>/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<String> = 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<String> = 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() {