@@ -1,7 +1,8 @@
//! `new project <name>` chat command — Phase 3 : SSH-remote editor acces s.
//! `new project <name>` chat command — Phase 4 : `--git` clone and push credential s.
//!
//! 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 2200– 2300 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 \n Partial 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 \n Partial 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 \n Partial state removed at ` {} `. " ,
" Scaffold failed: {e} \n \n Partial 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 \n Partial state removed at ` {} `. " ,
stderr . trim ( ) ,
" Scaffold failed: {e } \n \n Partial 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 \n Partial 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 \n Partial 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 \n transport = \" matrix \" \n git_user_name = \" Test User \" \n git_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 \n transport = \" matrix \" \n git_user_name = \" Bot Name \" \n git_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 ( ) {