use std::{
fs::{self, OpenOptions},
io::Write,
path::{Path, PathBuf},
process::Command,
};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use agent_infra_sdk::{
deploy_contract::{OwnerKind, OwnerRef, RuntimeRepositoryRecord},
runtime_repository_checkout_dir, runtime_repository_env_local, unique_owner_id,
};
use anyhow::{Context, Result, bail};
pub struct CheckoutOptions {
pub dir: Option<PathBuf>,
pub clone: bool,
pub agent_id: Option<String>,
pub runtime_id: Option<String>,
}
pub fn merge_identity_owners(
mut owners: Vec<OwnerRef>,
agent_id: Option<&str>,
runtime_id: Option<&str>,
) -> Vec<OwnerRef> {
if let Some(id) = agent_id.map(str::trim).filter(|id| !id.is_empty()) {
owners.push(OwnerRef::agent(id));
}
if let Some(id) = runtime_id.map(str::trim).filter(|id| !id.is_empty()) {
owners.push(OwnerRef::runtime(id));
}
owners
}
pub fn checkout_runtime_repository(
record: &RuntimeRepositoryRecord,
options: &CheckoutOptions,
) -> Result<PathBuf> {
let agent_id = resolve_identity_id(
options.agent_id.as_deref(),
&record.owners,
OwnerKind::Agent,
"--agent-id",
)?;
let runtime_id = resolve_identity_id(
options.runtime_id.as_deref(),
&record.owners,
OwnerKind::Runtime,
"--runtime-id",
)?;
let dir = match &options.dir {
Some(path) => path.clone(),
None if options.clone => PathBuf::from(
runtime_repository_checkout_dir(&record.path_with_namespace)
.context("repository pathWithNamespace has no checkout directory name")?,
),
None => PathBuf::from("."),
};
if let Some(parent) = dir.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
fs::create_dir_all(parent)?;
}
}
if options.clone {
git_clone(
&record.repository_url,
record.default_branch.as_deref(),
&dir,
)?;
} else if !dir.exists() {
fs::create_dir_all(&dir)?;
}
ensure_gitignore_env_local(&dir)?;
write_env_local(
&dir.join(".env.local"),
&runtime_repository_env_local(record, agent_id.as_deref(), runtime_id.as_deref()),
)?;
Ok(dir)
}
fn resolve_identity_id(
flag: Option<&str>,
owners: &[OwnerRef],
kind: OwnerKind,
flag_name: &str,
) -> Result<Option<String>> {
if let Some(id) = flag.map(str::trim).filter(|id| !id.is_empty()) {
return Ok(Some(id.to_string()));
}
match unique_owner_id(owners, kind) {
Ok(id) => Ok(id),
Err(_) => bail!("multiple {} owners; pass {flag_name}", kind.as_str()),
}
}
fn git_clone(url: &str, branch: Option<&str>, dir: &Path) -> Result<()> {
if dir.join(".git").is_dir() {
return Ok(());
}
if dir.exists() {
let empty = fs::read_dir(dir)?.next().is_none();
if !empty {
bail!(
"checkout directory {} exists and is not a git repository",
dir.display()
);
}
}
let branch = branch.filter(|value| !value.is_empty()).unwrap_or("master");
let status = Command::new("git")
.arg("clone")
.arg("--branch")
.arg(branch)
.arg("--single-branch")
.arg(url)
.arg(dir)
.env("GIT_TERMINAL_PROMPT", "0")
.status()
.with_context(|| format!("git clone {url}"))?;
if !status.success() {
bail!("git clone failed for {url}; configure GitLab credentials and retry");
}
Ok(())
}
fn ensure_gitignore_env_local(dir: &Path) -> Result<()> {
let path = dir.join(".gitignore");
if path.exists() {
let text = fs::read_to_string(&path)?;
if text.lines().any(|line| line.trim() == ".env.local") {
return Ok(());
}
let mut file = OpenOptions::new().append(true).open(&path)?;
if !text.ends_with('\n') {
file.write_all(b"\n")?;
}
file.write_all(b".env.local\n")?;
return Ok(());
}
fs::write(path, ".env.local\n")?;
Ok(())
}
fn write_env_local(path: &Path, contents: &str) -> Result<()> {
let mut options = OpenOptions::new();
options.create(true).write(true).truncate(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options
.open(path)
.with_context(|| format!("write {}", path.display()))?;
file.write_all(contents.as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = file.metadata()?.permissions();
permissions.set_mode(0o600);
file.set_permissions(permissions)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use agent_infra_sdk::deploy_contract::RuntimeRepositoryRecord;
fn record(owners: Vec<OwnerRef>, url: &str) -> RuntimeRepositoryRecord {
RuntimeRepositoryRecord {
id: "rrepo-1".into(),
tenant_id: "local".into(),
project_id: "default".into(),
template_id: "rtpl-1".into(),
owners,
namespace: "runtimes".into(),
gitlab_project_id: 1,
path_with_namespace: "runtimes/demo-runtime".into(),
repository_url: url.into(),
default_branch: Some("master".into()),
status: Default::default(),
error_message: None,
version: 1,
created_at: "t".into(),
updated_at: "t".into(),
}
}
fn git(args: &[&str], cwd: &Path) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.env("GIT_TERMINAL_PROMPT", "0")
.status()
.unwrap();
assert!(status.success(), "git {args:?}");
}
#[test]
fn merge_identity_owners_appends_flags() {
let owners = merge_identity_owners(vec![OwnerRef::user("u1")], Some("agt_1"), Some("rt-1"));
assert!(owners.contains(&OwnerRef::agent("agt_1")));
assert!(owners.contains(&OwnerRef::runtime("rt-1")));
}
#[test]
fn checkout_without_clone_writes_gitignored_env() {
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("demo");
let checkout = checkout_runtime_repository(
&record(vec![OwnerRef::agent("agt_1")], "https://git.test/demo.git"),
&CheckoutOptions {
dir: Some(dir.clone()),
clone: false,
agent_id: None,
runtime_id: None,
},
)
.unwrap();
assert_eq!(checkout, dir);
let env = fs::read_to_string(dir.join(".env.local")).unwrap();
assert!(env.contains("AGENT_ID=agt_1"));
assert!(env.contains("RREPO_ID=rrepo-1"));
assert_eq!(
fs::read_to_string(dir.join(".gitignore")).unwrap().trim(),
".env.local"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(dir.join(".env.local"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
}
#[test]
fn checkout_rejects_ambiguous_agent_without_flag() {
let root = tempfile::tempdir().unwrap();
let error = checkout_runtime_repository(
&record(
vec![OwnerRef::agent("agt_1"), OwnerRef::agent("agt_2")],
"https://git.test/demo.git",
),
&CheckoutOptions {
dir: Some(root.path().join("demo")),
clone: false,
agent_id: None,
runtime_id: None,
},
)
.unwrap_err();
assert!(error.to_string().contains("--agent-id"), "{error}");
}
#[test]
fn checkout_clones_single_branch_and_skips_existing_git_dir() {
let root = tempfile::tempdir().unwrap();
let origin = root.path().join("origin.git");
fs::create_dir_all(&origin).unwrap();
git(&["-c", "init.defaultBranch=master", "init"], &origin);
fs::write(origin.join("README.md"), "hello").unwrap();
git(&["add", "README.md"], &origin);
git(
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
&origin,
);
let dest = root.path().join("demo-runtime");
let record = record(Vec::new(), origin.to_str().unwrap());
checkout_runtime_repository(
&record,
&CheckoutOptions {
dir: Some(dest.clone()),
clone: true,
agent_id: Some("agt_1".into()),
runtime_id: Some("rt-1".into()),
},
)
.unwrap();
assert!(dest.join("README.md").is_file());
assert!(dest.join(".env.local").is_file());
fs::write(dest.join("README.md"), "changed").unwrap();
checkout_runtime_repository(
&record,
&CheckoutOptions {
dir: Some(dest.clone()),
clone: true,
agent_id: Some("agt_1".into()),
runtime_id: Some("rt-1".into()),
},
)
.unwrap();
assert_eq!(
fs::read_to_string(dest.join("README.md")).unwrap(),
"changed"
);
}
#[test]
fn checkout_rejects_existing_non_git_directory() {
let root = tempfile::tempdir().unwrap();
let dest = root.path().join("demo");
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("keep.txt"), "nope").unwrap();
let error = checkout_runtime_repository(
&record(Vec::new(), "https://git.test/demo.git"),
&CheckoutOptions {
dir: Some(dest),
clone: true,
agent_id: None,
runtime_id: None,
},
)
.unwrap_err();
assert!(
error.to_string().contains("not a git repository"),
"{error}"
);
}
#[test]
fn checkout_appends_env_local_to_existing_gitignore() {
let root = tempfile::tempdir().unwrap();
let dest = root.path().join("demo");
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join(".gitignore"), "target\n").unwrap();
checkout_runtime_repository(
&record(Vec::new(), "https://git.test/demo.git"),
&CheckoutOptions {
dir: Some(dest.clone()),
clone: false,
agent_id: None,
runtime_id: None,
},
)
.unwrap();
assert_eq!(
fs::read_to_string(dest.join(".gitignore")).unwrap(),
"target\n.env.local\n"
);
}
}