use crate::cli::output::Output;
use crate::core::detect::{detect_toolchain, DetectedToolchain};
use crate::core::gripspace::{ensure_gripspace, resolve_all_gripspaces};
use crate::core::manifest::{
AgentContextTarget, HookCommand, Manifest, ManifestSettings, PlatformType, RepoAgentConfig,
RepoConfig, ScriptStep, WorkspaceAgentConfig, WorkspaceConfig, WorkspaceHooks, WorkspaceScript,
};
use crate::core::manifest_paths;
use crate::git::clone_repo;
use crate::platform;
use crate::util::log_cmd;
use dialoguer::{theme::ColorfulTheme, Confirm, Editor, MultiSelect, Select};
use git2::Repository;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct DiscoveredRepo {
pub name: String,
pub path: String,
pub absolute_path: PathBuf,
pub url: Option<String>,
pub default_branch: String,
pub toolchain: Option<DetectedToolchain>,
}
pub struct InitOptions<'a> {
pub url: Option<&'a str>,
pub path: Option<&'a str>,
pub from_dirs: bool,
pub dirs: &'a [String],
pub interactive: bool,
pub create_manifest: bool,
pub manifest_name: Option<&'a str>,
pub private: bool,
pub from_repo: bool,
}
pub async fn run_init(opts: InitOptions<'_>) -> anyhow::Result<()> {
if opts.from_repo {
run_init_from_repo(opts.path)
} else if opts.from_dirs {
run_init_from_dirs(
opts.path,
opts.dirs,
opts.interactive,
opts.create_manifest,
opts.manifest_name,
opts.private,
)
.await
} else {
run_init_from_url(opts.url, opts.path)
}
}
fn run_init_from_repo(path: Option<&str>) -> anyhow::Result<()> {
use crate::core::repo_manifest::XmlManifest;
let workspace_root = match path {
Some(p) => PathBuf::from(p),
None => std::env::current_dir()?,
};
let repo_dir = workspace_root.join(".repo");
if !repo_dir.exists() {
anyhow::bail!(
"No .repo/ directory found in {:?}. Run 'repo init' and 'repo sync' first.",
workspace_root
);
}
let manifest_xml = repo_dir.join("manifest.xml");
if !manifest_xml.exists() {
anyhow::bail!("No .repo/manifest.xml found. Ensure 'repo init' has been run.");
}
Output::header("Initializing gitgrip from .repo/ workspace...");
println!();
let xml_manifest = XmlManifest::parse_file(&manifest_xml)?;
let result = xml_manifest.to_manifest()?;
let mut platform_parts: Vec<String> = result
.platform_counts
.iter()
.map(|(p, c)| format!("{}: {}", p, c))
.collect();
platform_parts.sort();
Output::info(&format!(
"Imported {} non-Gerrit repos ({})",
result.non_gerrit_imported,
platform_parts.join(", ")
));
if result.gerrit_skipped > 0 {
Output::info(&format!(
"Skipped {} Gerrit repos (managed by repo upload)",
result.gerrit_skipped
));
}
let manifests_dir = repo_dir.join("manifests");
if !manifests_dir.exists() {
anyhow::bail!(".repo/manifests/ directory not found");
}
let yaml = serde_yaml::to_string(&result.manifest)?;
let yaml_path = manifests_dir.join("manifest.yaml");
std::fs::write(&yaml_path, &yaml)?;
let gitgrip_dir = workspace_root.join(".gitgrip");
std::fs::create_dir_all(&gitgrip_dir)?;
let state_path = gitgrip_dir.join("state.json");
if !state_path.exists() {
std::fs::write(&state_path, "{}")?;
}
println!();
Output::success(&format!("Written: {}", yaml_path.display()));
println!();
println!("Now use: gr pr create, gr pr status, gr pr merge");
Ok(())
}
fn run_init_from_url(url: Option<&str>, path: Option<&str>) -> anyhow::Result<()> {
let manifest_url = match url {
Some(u) => u.to_string(),
None => {
anyhow::bail!("Manifest URL required. Usage: gr init <manifest-url>");
}
};
let target_dir = match path {
Some(p) => PathBuf::from(p),
None => {
let name = extract_repo_name(&manifest_url).unwrap_or_else(|| "workspace".to_string());
std::env::current_dir()?.join(name)
}
};
Output::header(&format!("Initializing workspace in {:?}", target_dir));
println!();
if target_dir.exists() {
anyhow::bail!(
"Directory already exists: {:?}. Use a different path or remove the existing directory.",
target_dir
);
}
std::fs::create_dir_all(&target_dir)?;
let gitgrip_dir = target_dir.join(".gitgrip");
let manifests_dir = manifest_paths::main_space_dir(&target_dir);
let local_space_dir = manifest_paths::local_space_dir(&target_dir);
std::fs::create_dir_all(&manifests_dir)?;
std::fs::create_dir_all(&local_space_dir)?;
let spinner = Output::spinner("Cloning manifest repository...");
match clone_repo(&manifest_url, &manifests_dir, None) {
Ok(_) => {
spinner.finish_with_message("Manifest cloned successfully");
}
Err(e) => {
spinner.finish_with_message(format!("Failed to clone manifest: {}", e));
let _ = std::fs::remove_dir_all(&target_dir);
return Err(e.into());
}
}
let manifest_path =
if let Some(path) = manifest_paths::resolve_manifest_file_in_dir(&manifests_dir) {
path
} else {
let _ = std::fs::remove_dir_all(&target_dir);
anyhow::bail!(
"No workspace manifest found in repository. \
Expected gripspace.yml (preferred) or manifest.yaml/manifest.yml at repo root."
);
};
let state_path = gitgrip_dir.join("state.json");
std::fs::write(&state_path, "{}")?;
let manifest_content = std::fs::read_to_string(&manifest_path)?;
let mut manifest = Manifest::parse_raw(&manifest_content)?;
if let Some(ref gripspaces) = manifest.gripspaces {
if !gripspaces.is_empty() {
let spaces_dir = manifest_paths::spaces_dir(&target_dir);
let spinner = Output::spinner(&format!("Cloning {} gripspace(s)...", gripspaces.len()));
for gs_config in gripspaces {
if let Err(e) = ensure_gripspace(&spaces_dir, gs_config) {
Output::warning(&format!(
"Gripspace '{}' clone failed: {}",
gs_config.url, e
));
continue;
}
}
spinner.finish_with_message("Gripspaces cloned");
if let Err(e) = resolve_all_gripspaces(&mut manifest, &spaces_dir) {
Output::warning(&format!("Gripspace resolution failed: {}", e));
}
}
}
if let Err(e) = manifest.validate() {
Output::warning(&format!("Manifest validation: {}", e));
}
let repo_count = manifest.repos.len();
if repo_count > 0 {
println!();
let spinner = Output::spinner(&format!("Cloning {} repositories...", repo_count));
let mut cloned = 0;
let mut failed = Vec::new();
for (name, config) in &manifest.repos {
let repo_path = target_dir.join(&config.path);
if repo_path.exists() {
continue;
}
let url = config.url.clone().or_else(|| {
config.remote.as_ref().and_then(|remote_name| {
manifest.remotes.as_ref()?.get(remote_name).map(|rc| {
let base = rc.fetch.trim_end_matches('/');
format!("{}/{}.git", base, name)
})
})
});
let url = match url {
Some(u) if !u.is_empty() => u,
_ => {
failed.push((name.clone(), "no URL configured".to_string()));
continue;
}
};
let revision = config
.revision
.as_deref()
.or(manifest.settings.revision.as_deref());
match clone_repo(&url, &repo_path, revision) {
Ok(_) => {
cloned += 1;
spinner.set_message(format!("Cloned {}/{}: {}", cloned, repo_count, name));
}
Err(e) => {
failed.push((name.clone(), format!("{}", e)));
}
}
}
if failed.is_empty() {
spinner.finish_with_message(format!("All {} repositories cloned", cloned));
} else {
spinner.finish_with_message(format!("{} cloned, {} failed", cloned, failed.len()));
for (name, err) in &failed {
Output::warning(&format!(" {} - {}", name, err));
}
}
}
if let Err(e) = super::link::apply_links(&target_dir, &manifest, false) {
Output::warning(&format!("Link application: {}", e));
}
println!();
Output::success("Workspace initialized and synced!");
println!();
println!("Next steps:");
println!(" cd {:?}", target_dir);
println!(" gr status # Verify workspace state");
Ok(())
}
async fn run_init_from_dirs(
path: Option<&str>,
dirs: &[String],
interactive: bool,
create_manifest: bool,
manifest_name: Option<&str>,
private: bool,
) -> anyhow::Result<()> {
let workspace_root = match path {
Some(p) => PathBuf::from(p),
None => std::env::current_dir()?,
};
let gitgrip_dir = workspace_root.join(".gitgrip");
if gitgrip_dir.exists() {
anyhow::bail!(
"A gitgrip workspace already exists at {:?}. \
Remove .gitgrip directory to reinitialize.",
workspace_root
);
}
Output::header(&format!("Discovering repositories in {:?}", workspace_root));
println!();
let specific_dirs: Option<&[String]> = if dirs.is_empty() { None } else { Some(dirs) };
let mut discovered = discover_repos(&workspace_root, specific_dirs)?;
if discovered.is_empty() {
anyhow::bail!(
"No git repositories found. Make sure directories contain .git folders.\n\
Tip: Use --dirs to specify directories explicitly."
);
}
ensure_unique_names(&mut discovered);
println!("Found {} repositories:", discovered.len());
println!();
for repo in &discovered {
let url_display = repo.url.as_deref().unwrap_or("(no remote)");
let lang_display = repo
.toolchain
.as_ref()
.map(|t| {
let pm = t
.package_manager
.as_deref()
.map(|p| format!(" ({p})"))
.unwrap_or_default();
format!(" [{}{}]", t.language, pm)
})
.unwrap_or_default();
Output::list_item(&format!(
"{}{} → {} ({})",
repo.name, lang_display, repo.path, url_display
));
}
println!();
let manifest = if interactive {
match run_interactive_init(&workspace_root, &mut discovered)? {
Some(m) => m,
None => {
Output::info("Initialization cancelled.");
return Ok(());
}
}
} else {
generate_manifest(&discovered, &ManifestGenerationOptions::default())
};
let manifests_dir = manifest_paths::main_space_dir(&workspace_root);
let local_space_dir = manifest_paths::local_space_dir(&workspace_root);
std::fs::create_dir_all(&manifests_dir)?;
std::fs::create_dir_all(&local_space_dir)?;
let manifest_path = manifests_dir.join(manifest_paths::PRIMARY_FILE_NAME);
let yaml_content = manifest_to_yaml(&manifest)?;
std::fs::write(&manifest_path, &yaml_content)?;
let legacy_manifest_path = manifest_paths::legacy_manifest_dir(&workspace_root)
.join(manifest_paths::LEGACY_FILE_NAMES[0]);
if let Some(parent) = legacy_manifest_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&legacy_manifest_path, &yaml_content)?;
let state_path = gitgrip_dir.join("state.json");
std::fs::write(&state_path, "{}")?;
init_manifest_repo(&manifests_dir)?;
let mut manifest_remote_url = None;
if create_manifest {
if let Some(detected) = detect_common_platform(&discovered) {
let repo_name = manifest_name.unwrap_or("workspace-manifest");
println!();
Output::info(&format!(
"Detected platform: {} (owner: {}, confidence: {:.0}%)",
detected.platform,
detected.owner,
detected.confidence * 100.0
));
let suggested_url = suggest_manifest_url(detected.platform, &detected.owner, repo_name);
Output::info(&format!("Creating manifest repo: {}", suggested_url));
let adapter = platform::get_platform_adapter(detected.platform, None);
match adapter
.create_repository(
&detected.owner,
repo_name,
Some("Workspace manifest repository for gitgrip"),
private,
)
.await
{
Ok(clone_url) => {
Output::success(&format!("Created repository: {}", clone_url));
let mut cmd = Command::new("git");
cmd.args(["remote", "add", "origin", &clone_url])
.current_dir(&manifests_dir);
log_cmd(&cmd);
let output = cmd.output()?;
if output.status.success() {
Output::success("Added remote 'origin' to manifest repo");
manifest_remote_url = Some(clone_url);
let mut cmd = Command::new("git");
cmd.args(["push", "-u", "origin", "main"])
.current_dir(&manifests_dir);
log_cmd(&cmd);
let push_output = cmd.output()?;
if push_output.status.success() {
Output::success("Pushed initial commit to remote");
} else {
let mut cmd = Command::new("git");
cmd.args(["push", "-u", "origin", "master"])
.current_dir(&manifests_dir);
log_cmd(&cmd);
let push_output = cmd.output()?;
if push_output.status.success() {
Output::success("Pushed initial commit to remote");
} else {
let stderr = String::from_utf8_lossy(&push_output.stderr);
Output::warning(&format!(
"Could not push: {}. You may need to push manually.",
stderr.trim()
));
}
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Output::warning(&format!(
"Could not add remote: {}. You may need to add it manually.",
stderr.trim()
));
}
}
Err(e) => {
Output::warning(&format!(
"Could not create repository on {}: {}",
detected.platform, e
));
Output::info("You can create the repository manually and add it as a remote.");
}
}
} else {
Output::warning("Could not detect platform from repositories. No remote URLs found.");
Output::info("You can create the manifest repository manually and add it as a remote.");
}
}
println!();
Output::success("Workspace initialized successfully!");
println!();
println!("Manifest created at: {}", manifest_path.display());
println!();
if let Some(url) = manifest_remote_url {
println!("Manifest remote: {}", url);
println!();
println!("Next steps:");
println!(" 1. Review the manifest: cat .gitgrip/spaces/main/gripspace.yml");
println!(" (legacy mirror at .gitgrip/manifests/manifest.yaml for compatibility)");
println!(" 2. Run 'gr status' to verify your workspace");
} else {
println!("Next steps:");
println!(" 1. Review the manifest: cat .gitgrip/spaces/main/gripspace.yml");
println!(" (legacy mirror at .gitgrip/manifests/manifest.yaml for compatibility)");
println!(" 2. Add a remote to the manifest repo:");
println!(" cd .gitgrip/spaces/main && git remote add origin <your-manifest-url>");
println!(" 3. Run 'gr status' to verify your workspace");
}
Ok(())
}
fn discover_repos(
base_dir: &Path,
specific_dirs: Option<&[String]>,
) -> anyhow::Result<Vec<DiscoveredRepo>> {
let mut repos = Vec::new();
let dirs_to_scan: Vec<PathBuf> = match specific_dirs {
Some(dirs) => dirs
.iter()
.map(|d| {
let p = PathBuf::from(d);
if p.is_absolute() {
p
} else {
base_dir.join(d)
}
})
.collect(),
None => {
std::fs::read_dir(base_dir)?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|p| p.is_dir())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| !n.starts_with('.'))
.unwrap_or(false)
})
.collect()
}
};
for dir in dirs_to_scan {
if let Some(repo) = try_discover_repo(base_dir, &dir)? {
repos.push(repo);
}
}
repos.sort_by(|a, b| a.name.cmp(&b.name));
Ok(repos)
}
fn try_discover_repo(workspace_root: &Path, dir: &Path) -> anyhow::Result<Option<DiscoveredRepo>> {
let git_dir = dir.join(".git");
if !git_dir.exists() {
return Ok(None);
}
let repo = match Repository::open(dir) {
Ok(r) => r,
Err(_) => return Ok(None),
};
let name = dir
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "repo".to_string());
let path = dir
.strip_prefix(workspace_root)
.map(|p| format!("./{}", p.display()))
.unwrap_or_else(|_| dir.display().to_string());
let url = get_remote_url(&repo);
let default_branch = detect_default_branch(&repo).unwrap_or_else(|_| "main".to_string());
let toolchain = detect_toolchain(dir);
Ok(Some(DiscoveredRepo {
name,
path,
absolute_path: dir.to_path_buf(),
url,
default_branch,
toolchain,
}))
}
fn get_remote_url(repo: &Repository) -> Option<String> {
if let Ok(remote) = repo.find_remote("origin") {
if let Some(url) = remote.url() {
return Some(url.to_string());
}
}
if let Ok(remotes) = repo.remotes() {
for remote_name in remotes.iter().flatten() {
if let Ok(remote) = repo.find_remote(remote_name) {
if let Some(url) = remote.url() {
return Some(url.to_string());
}
}
}
}
None
}
fn detect_default_branch(repo: &Repository) -> anyhow::Result<String> {
if let Ok(reference) = repo.find_reference("refs/remotes/origin/HEAD") {
if let Ok(resolved) = reference.resolve() {
if let Some(name) = resolved.shorthand() {
if let Some(branch) = name.strip_prefix("origin/") {
return Ok(branch.to_string());
}
}
}
}
for branch_name in &["main", "master"] {
if repo
.find_branch(&format!("origin/{}", branch_name), git2::BranchType::Remote)
.is_ok()
{
return Ok(branch_name.to_string());
}
}
for branch_name in &["main", "master", "develop"] {
if repo
.find_branch(branch_name, git2::BranchType::Local)
.is_ok()
{
return Ok(branch_name.to_string());
}
}
Ok("main".to_string())
}
fn ensure_unique_names(repos: &mut [DiscoveredRepo]) {
let mut name_counts: HashMap<String, usize> = HashMap::new();
for repo in repos.iter() {
*name_counts.entry(repo.name.clone()).or_insert(0) += 1;
}
let all_names: HashSet<String> = repos.iter().map(|r| r.name.clone()).collect();
let mut used_names: HashSet<String> = all_names;
let mut name_indices: HashMap<String, usize> = HashMap::new();
for repo in repos.iter_mut() {
if name_counts[&repo.name] > 1 {
let idx = name_indices.entry(repo.name.clone()).or_insert(1);
if *idx > 1 {
let base = repo.name.clone();
let mut suffix = *idx;
let mut candidate = format!("{}-{}", base, suffix);
while used_names.contains(&candidate) {
suffix += 1;
candidate = format!("{}-{}", base, suffix);
}
repo.name = candidate.clone();
used_names.insert(candidate);
} else {
}
*idx += 1;
}
}
}
struct ManifestGenerationOptions {
include_post_sync_hooks: bool,
agent_targets: Vec<AgentContextTarget>,
post_sync_repos: Option<Vec<String>>,
}
impl Default for ManifestGenerationOptions {
fn default() -> Self {
Self {
include_post_sync_hooks: true,
agent_targets: Vec::new(),
post_sync_repos: None,
}
}
}
fn generate_manifest(repos: &[DiscoveredRepo], options: &ManifestGenerationOptions) -> Manifest {
let mut repo_configs = HashMap::new();
let mut scripts = HashMap::new();
let mut build_steps = Vec::new();
let mut test_steps = Vec::new();
let mut post_sync_hooks = Vec::new();
for repo in repos {
let url = repo
.url
.clone()
.unwrap_or_else(|| format!("git@github.com:OWNER/{}.git", repo.name));
let agent = repo.toolchain.as_ref().map(|t| RepoAgentConfig {
description: None,
language: Some(t.language.clone()),
build: t.build.clone(),
test: t.test.clone(),
lint: t.lint.clone(),
format: t.format.clone(),
});
if let Some(tc) = &repo.toolchain {
if let Some(build) = &tc.build {
scripts.insert(
format!("build-{}", repo.name),
WorkspaceScript {
description: Some(format!("Build {}", repo.name)),
command: Some(build.clone()),
cwd: Some(repo.path.clone()),
steps: None,
},
);
build_steps.push(ScriptStep {
name: format!("Build {}", repo.name),
command: build.clone(),
cwd: Some(repo.path.clone()),
});
}
if let Some(test) = &tc.test {
scripts.insert(
format!("test-{}", repo.name),
WorkspaceScript {
description: Some(format!("Test {}", repo.name)),
command: Some(test.clone()),
cwd: Some(repo.path.clone()),
steps: None,
},
);
test_steps.push(ScriptStep {
name: format!("Test {}", repo.name),
command: test.clone(),
cwd: Some(repo.path.clone()),
});
}
if options.include_post_sync_hooks {
if let Some(install) = &tc.install {
let include = match &options.post_sync_repos {
Some(selected) => selected.contains(&repo.name),
None => true,
};
if include {
post_sync_hooks.push(HookCommand {
command: install.clone(),
cwd: Some(repo.path.clone()),
name: Some(format!("Install {}", repo.name)),
repos: None,
condition: Default::default(),
});
}
}
}
}
repo_configs.insert(
repo.name.clone(),
RepoConfig {
url: Some(url),
remote: None,
path: repo.path.clone(),
revision: Some(repo.default_branch.clone()),
target: None,
sync_remote: None,
push_remote: None,
copyfile: None,
linkfile: None,
platform: None,
reference: false,
groups: Vec::new(),
agent,
clone_strategy: None,
},
);
}
if build_steps.len() > 1 {
scripts.insert(
"build-all".to_string(),
WorkspaceScript {
description: Some("Build all repositories".to_string()),
command: None,
cwd: None,
steps: Some(build_steps),
},
);
}
if test_steps.len() > 1 {
scripts.insert(
"test-all".to_string(),
WorkspaceScript {
description: Some("Test all repositories".to_string()),
command: None,
cwd: None,
steps: Some(test_steps),
},
);
}
let hooks = if post_sync_hooks.is_empty() {
None
} else {
Some(WorkspaceHooks {
post_sync: Some(post_sync_hooks),
post_checkout: None,
})
};
let workspace_agent = WorkspaceAgentConfig {
description: Some(format!(
"Multi-repo workspace with {} repositories",
repos.len()
)),
conventions: vec![
"Use `gr` for all git operations (not raw git/gh)".to_string(),
"All development on feature branches — never push to main".to_string(),
],
workflows: {
let mut wf = HashMap::new();
if scripts.contains_key("build-all") {
wf.insert("build".to_string(), "gr run build-all".to_string());
} else if let Some((name, _)) = scripts.iter().find(|(k, _)| k.starts_with("build-")) {
wf.insert("build".to_string(), format!("gr run {name}"));
}
if scripts.contains_key("test-all") {
wf.insert("test".to_string(), "gr run test-all".to_string());
} else if let Some((name, _)) = scripts.iter().find(|(k, _)| k.starts_with("test-")) {
wf.insert("test".to_string(), format!("gr run {name}"));
}
wf.insert("sync".to_string(), "gr sync".to_string());
Some(wf)
},
context_source: None,
targets: if options.agent_targets.is_empty() {
None
} else {
Some(options.agent_targets.clone())
},
};
let workspace = Some(WorkspaceConfig {
env: None,
scripts: if scripts.is_empty() {
None
} else {
Some(scripts)
},
hooks,
ci: None,
agent: Some(workspace_agent),
release: None,
});
Manifest {
version: 2,
remotes: None,
gripspaces: None,
manifest: None,
repos: repo_configs,
settings: ManifestSettings::default(),
workspace,
}
}
fn manifest_to_yaml(manifest: &Manifest) -> anyhow::Result<String> {
let yaml = serde_yaml::to_string(manifest)?;
Ok(add_yaml_section_comments(&yaml))
}
fn add_yaml_section_comments(yaml: &str) -> String {
let mut result = String::with_capacity(yaml.len() + 200);
result.push_str("# Generated by gr init --from-dirs\n");
for line in yaml.lines() {
if line == "repos:" {
result.push_str("\n# Repository definitions\n");
} else if line == "workspace:" {
result.push_str("\n# Workspace configuration (scripts, hooks, agent context)\n");
} else if line == "settings:" {
result.push_str("\n# Global settings\n");
}
result.push_str(line);
result.push('\n');
}
result
}
fn run_interactive_init(
_workspace_root: &Path,
discovered: &mut Vec<DiscoveredRepo>,
) -> anyhow::Result<Option<Manifest>> {
let theme = ColorfulTheme::default();
let all_discovered = discovered.clone();
'wizard: loop {
*discovered = all_discovered.clone();
let include_all = Confirm::with_theme(&theme)
.with_prompt(format!("Include all {} repositories?", discovered.len()))
.default(true)
.interact()?;
if !include_all {
let items: Vec<String> = discovered
.iter()
.map(|r| {
let lang = r
.toolchain
.as_ref()
.map(|t| format!(" [{}]", t.language))
.unwrap_or_default();
format!("{}{} ({})", r.name, lang, r.path)
})
.collect();
let defaults: Vec<bool> = discovered.iter().map(|_| true).collect();
let selected = MultiSelect::with_theme(&theme)
.with_prompt("Select repositories to include")
.items(&items)
.defaults(&defaults)
.interact()?;
if selected.is_empty() {
Output::warning("No repositories selected. Please select at least one.");
continue 'wizard;
}
let mut kept = Vec::new();
for (i, repo) in discovered.drain(..).enumerate() {
if selected.contains(&i) {
kept.push(repo);
}
}
*discovered = kept;
}
let installable: Vec<(usize, String, String)> = discovered
.iter()
.enumerate()
.filter_map(|(i, r)| {
r.toolchain.as_ref().and_then(|t| {
t.install
.as_ref()
.map(|cmd| (i, r.name.clone(), cmd.clone()))
})
})
.collect();
let mut post_sync_repos: Option<Vec<String>> = None;
if !installable.is_empty() {
let hook_items: Vec<String> = installable
.iter()
.map(|(_, name, cmd)| format!("{} → {}", name, cmd))
.collect();
let hook_defaults: Vec<bool> = installable.iter().map(|_| true).collect();
println!();
let selected_hooks = MultiSelect::with_theme(&theme)
.with_prompt("Configure post-sync hooks? (auto-install dependencies after gr sync)")
.items(&hook_items)
.defaults(&hook_defaults)
.interact()?;
let selected_names: Vec<String> = selected_hooks
.iter()
.map(|&i| installable[i].1.clone())
.collect();
post_sync_repos = Some(selected_names);
}
let agent_options = vec![
"Yes, for Claude Code",
"Yes, for all tools (Claude, OpenCode, Codex)",
"Skip",
];
println!();
let agent_selection = Select::with_theme(&theme)
.with_prompt("Generate agent context files?")
.items(&agent_options)
.default(0)
.interact()?;
let agent_targets = match agent_selection {
0 => vec![AgentContextTarget {
format: "claude".to_string(),
dest: ".claude/skills/{repo}/SKILL.md".to_string(),
compose_with: None,
}],
1 => vec![
AgentContextTarget {
format: "claude".to_string(),
dest: ".claude/skills/{repo}/SKILL.md".to_string(),
compose_with: None,
},
AgentContextTarget {
format: "opencode".to_string(),
dest: ".opencode/skill/{repo}/SKILL.md".to_string(),
compose_with: None,
},
AgentContextTarget {
format: "codex".to_string(),
dest: ".codex/skills/{repo}/SKILL.md".to_string(),
compose_with: None,
},
],
_ => vec![],
};
let options = ManifestGenerationOptions {
include_post_sync_hooks: true,
agent_targets,
post_sync_repos,
};
let manifest = generate_manifest(discovered, &options);
let yaml = manifest_to_yaml(&manifest)?;
println!();
println!("Generated gripspace.yml:");
println!("─────────────────────────────────────────");
println!("{}", yaml);
println!("─────────────────────────────────────────");
println!();
let review_options = vec!["Accept", "Edit in editor", "Start over"];
let review_selection = Select::with_theme(&theme)
.with_prompt("Review the manifest")
.items(&review_options)
.default(0)
.interact()?;
match review_selection {
0 => return Ok(Some(manifest)),
1 => {
if let Some(edited_yaml) = Editor::new().extension(".yaml").edit(&yaml)? {
match Manifest::parse(&edited_yaml) {
Ok(edited_manifest) => {
println!();
Output::success("Manifest validated successfully.");
return Ok(Some(edited_manifest));
}
Err(e) => {
Output::error(&format!("Invalid YAML: {}", e));
println!("Please fix the errors and try again.");
continue 'wizard;
}
}
} else {
Output::info("No changes made.");
continue 'wizard;
}
}
2 => continue 'wizard,
_ => unreachable!(),
}
}
}
fn init_manifest_repo(manifests_dir: &Path) -> anyhow::Result<()> {
let mut cmd = Command::new("git");
cmd.args(["init"]).current_dir(manifests_dir);
log_cmd(&cmd);
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to initialize manifest git repo: {}", stderr);
}
let manifest_file = manifest_paths::resolve_manifest_file_in_dir(manifests_dir)
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| manifest_paths::PRIMARY_FILE_NAME.to_string());
let mut cmd = Command::new("git");
cmd.args(["add", &manifest_file]).current_dir(manifests_dir);
log_cmd(&cmd);
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to stage {}: {}", manifest_file, stderr);
}
let mut cmd = Command::new("git");
cmd.args([
"commit",
"-m",
"Initial manifest\n\nGenerated by gr init --from-dirs",
])
.current_dir(manifests_dir);
log_cmd(&cmd);
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
Output::warning(&format!(
"Could not create initial commit: {}. You may need to commit manually.",
stderr.trim()
));
}
Ok(())
}
fn extract_repo_name(url: &str) -> Option<String> {
if url.starts_with("git@") {
let parts: Vec<&str> = url.split('/').collect();
if let Some(last) = parts.last() {
return Some(last.trim_end_matches(".git").to_string());
}
}
if url.starts_with("https://") || url.starts_with("http://") {
let parts: Vec<&str> = url.split('/').collect();
if let Some(last) = parts.last() {
return Some(last.trim_end_matches(".git").to_string());
}
}
None
}
#[derive(Debug, Clone)]
pub struct DetectedPlatform {
pub platform: PlatformType,
pub owner: String,
pub confidence: f32,
}
pub fn detect_common_platform(repos: &[DiscoveredRepo]) -> Option<DetectedPlatform> {
let repos_with_urls: Vec<_> = repos.iter().filter_map(|r| r.url.as_ref()).collect();
if repos_with_urls.is_empty() {
return None;
}
let mut platform_counts: HashMap<PlatformType, Vec<String>> = HashMap::new();
for url in &repos_with_urls {
let detected_platform = platform::detect_platform(url);
let adapter = platform::get_platform_adapter(detected_platform, None);
if let Some(info) = adapter.parse_repo_url(url) {
platform_counts
.entry(detected_platform)
.or_default()
.push(info.owner);
} else {
platform_counts.entry(detected_platform).or_default();
}
}
let (platform, owners) = platform_counts
.into_iter()
.max_by_key(|(_, owners)| owners.len())?;
let mut owner_counts: HashMap<String, usize> = HashMap::new();
for owner in &owners {
*owner_counts.entry(owner.clone()).or_insert(0) += 1;
}
let (owner, _) = owner_counts.into_iter().max_by_key(|(_, count)| *count)?;
let confidence = owners.len() as f32 / repos_with_urls.len() as f32;
Some(DetectedPlatform {
platform,
owner,
confidence,
})
}
pub fn suggest_manifest_url(platform: PlatformType, owner: &str, name: &str) -> String {
match platform {
PlatformType::GitHub => format!("git@github.com:{}/{}.git", owner, name),
PlatformType::GitLab => format!("git@gitlab.com:{}/{}.git", owner, name),
PlatformType::AzureDevOps => {
format!("git@ssh.dev.azure.com:v3/{}/{}.git", owner, name)
}
PlatformType::Bitbucket => format!("git@bitbucket.org:{}/{}.git", owner, name),
}
}
pub fn suggest_manifest_https_url(platform: PlatformType, owner: &str, name: &str) -> String {
match platform {
PlatformType::GitHub => format!("https://github.com/{}/{}.git", owner, name),
PlatformType::GitLab => format!("https://gitlab.com/{}/{}.git", owner, name),
PlatformType::AzureDevOps => {
let parts: Vec<&str> = owner.split('/').collect();
if parts.len() >= 2 {
format!(
"https://dev.azure.com/{}/{}/_git/{}",
parts[0], parts[1], name
)
} else {
format!("https://dev.azure.com/{}/{}/_git/{}", owner, owner, name)
}
}
PlatformType::Bitbucket => format!("https://bitbucket.org/{}/{}.git", owner, name),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_extract_repo_name_ssh() {
assert_eq!(
extract_repo_name("git@github.com:user/my-workspace.git"),
Some("my-workspace".to_string())
);
}
#[test]
fn test_extract_repo_name_https() {
assert_eq!(
extract_repo_name("https://github.com/user/my-workspace.git"),
Some("my-workspace".to_string())
);
}
#[test]
fn test_extract_repo_name_no_extension() {
assert_eq!(
extract_repo_name("https://github.com/user/workspace"),
Some("workspace".to_string())
);
}
#[test]
fn test_ensure_unique_names() {
let mut repos = vec![
DiscoveredRepo {
name: "app".to_string(),
path: "./app1".to_string(),
absolute_path: PathBuf::from("/tmp/app1"),
url: None,
default_branch: "main".to_string(),
toolchain: None,
},
DiscoveredRepo {
name: "app".to_string(),
path: "./app2".to_string(),
absolute_path: PathBuf::from("/tmp/app2"),
url: None,
default_branch: "main".to_string(),
toolchain: None,
},
DiscoveredRepo {
name: "backend".to_string(),
path: "./backend".to_string(),
absolute_path: PathBuf::from("/tmp/backend"),
url: None,
default_branch: "main".to_string(),
toolchain: None,
},
];
ensure_unique_names(&mut repos);
assert_eq!(repos[0].name, "app");
assert_eq!(repos[1].name, "app-2");
assert_eq!(repos[2].name, "backend");
}
#[test]
fn test_generate_manifest() {
let repos = vec![
DiscoveredRepo {
name: "frontend".to_string(),
path: "./frontend".to_string(),
absolute_path: PathBuf::from("/tmp/frontend"),
url: Some("git@github.com:org/frontend.git".to_string()),
default_branch: "main".to_string(),
toolchain: None,
},
DiscoveredRepo {
name: "backend".to_string(),
path: "./backend".to_string(),
absolute_path: PathBuf::from("/tmp/backend"),
url: None,
default_branch: "master".to_string(),
toolchain: None,
},
];
let manifest = generate_manifest(&repos, &ManifestGenerationOptions::default());
assert_eq!(manifest.repos.len(), 2);
assert!(manifest.repos.contains_key("frontend"));
assert!(manifest.repos.contains_key("backend"));
assert_eq!(
manifest.repos["frontend"].url,
Some("git@github.com:org/frontend.git".to_string())
);
assert_eq!(
manifest.repos["frontend"].revision,
Some("main".to_string())
);
assert!(manifest.repos["backend"]
.url
.as_deref()
.unwrap()
.contains("OWNER"));
assert_eq!(
manifest.repos["backend"].revision,
Some("master".to_string())
);
}
#[test]
fn test_discover_repos_empty() {
let temp = TempDir::new().unwrap();
let repos = discover_repos(temp.path(), None).unwrap();
assert!(repos.is_empty());
}
#[test]
fn test_discover_repos_with_git_dir() {
let temp = TempDir::new().unwrap();
let repo_dir = temp.path().join("my-repo");
std::fs::create_dir_all(&repo_dir).unwrap();
Repository::init(&repo_dir).unwrap();
let repos = discover_repos(temp.path(), None).unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "my-repo");
}
#[test]
fn test_discover_repos_skips_hidden() {
let temp = TempDir::new().unwrap();
let hidden_dir = temp.path().join(".hidden-repo");
std::fs::create_dir_all(&hidden_dir).unwrap();
Repository::init(&hidden_dir).unwrap();
let repo_dir = temp.path().join("visible-repo");
std::fs::create_dir_all(&repo_dir).unwrap();
Repository::init(&repo_dir).unwrap();
let repos = discover_repos(temp.path(), None).unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "visible-repo");
}
#[test]
fn test_manifest_to_yaml() {
let repos = vec![DiscoveredRepo {
name: "test".to_string(),
path: "./test".to_string(),
absolute_path: PathBuf::from("/tmp/test"),
url: Some("git@github.com:org/test.git".to_string()),
default_branch: "main".to_string(),
toolchain: None,
}];
let manifest = generate_manifest(&repos, &ManifestGenerationOptions::default());
let yaml = manifest_to_yaml(&manifest).unwrap();
assert!(yaml.contains("repos:"));
assert!(yaml.contains("test:"));
assert!(yaml.contains("git@github.com:org/test.git"));
}
#[test]
fn test_generate_manifest_with_toolchains() {
use crate::core::detect::DetectedToolchain;
let repos = vec![
DiscoveredRepo {
name: "api".to_string(),
path: "./api".to_string(),
absolute_path: PathBuf::from("/tmp/api"),
url: Some("git@github.com:org/api.git".to_string()),
default_branch: "main".to_string(),
toolchain: Some(DetectedToolchain {
language: "rust".to_string(),
package_manager: Some("cargo".to_string()),
build: Some("cargo build".to_string()),
test: Some("cargo test".to_string()),
lint: Some("cargo clippy".to_string()),
format: Some("cargo fmt".to_string()),
install: None,
}),
},
DiscoveredRepo {
name: "web".to_string(),
path: "./web".to_string(),
absolute_path: PathBuf::from("/tmp/web"),
url: Some("git@github.com:org/web.git".to_string()),
default_branch: "main".to_string(),
toolchain: Some(DetectedToolchain {
language: "typescript".to_string(),
package_manager: Some("pnpm".to_string()),
build: Some("pnpm run build".to_string()),
test: Some("pnpm test".to_string()),
lint: Some("pnpm run lint".to_string()),
format: Some("pnpm run format".to_string()),
install: Some("pnpm install".to_string()),
}),
},
];
let manifest = generate_manifest(&repos, &ManifestGenerationOptions::default());
assert_eq!(manifest.version, 2);
let api = &manifest.repos["api"];
let agent = api.agent.as_ref().unwrap();
assert_eq!(agent.language.as_deref(), Some("rust"));
assert_eq!(agent.build.as_deref(), Some("cargo build"));
assert_eq!(agent.test.as_deref(), Some("cargo test"));
let web = &manifest.repos["web"];
let agent = web.agent.as_ref().unwrap();
assert_eq!(agent.language.as_deref(), Some("typescript"));
let ws = manifest.workspace.as_ref().unwrap();
let scripts = ws.scripts.as_ref().unwrap();
assert!(scripts.contains_key("build-api"));
assert!(scripts.contains_key("test-api"));
assert!(scripts.contains_key("build-web"));
assert!(scripts.contains_key("test-web"));
assert!(scripts.contains_key("build-all"));
assert!(scripts.contains_key("test-all"));
let build_all = &scripts["build-all"];
assert!(build_all.steps.is_some());
assert_eq!(build_all.steps.as_ref().unwrap().len(), 2);
let hooks = ws.hooks.as_ref().unwrap();
let post_sync = hooks.post_sync.as_ref().unwrap();
assert_eq!(post_sync.len(), 1);
assert_eq!(post_sync[0].command, "pnpm install");
let agent = ws.agent.as_ref().unwrap();
let workflows = agent.workflows.as_ref().unwrap();
assert_eq!(workflows["build"], "gr run build-all");
assert_eq!(workflows["test"], "gr run test-all");
}
#[test]
fn test_generate_manifest_single_repo_workflows() {
use crate::core::detect::DetectedToolchain;
let repos = vec![DiscoveredRepo {
name: "app".to_string(),
path: "./app".to_string(),
absolute_path: PathBuf::from("/tmp/app"),
url: Some("git@github.com:org/app.git".to_string()),
default_branch: "main".to_string(),
toolchain: Some(DetectedToolchain {
language: "rust".to_string(),
package_manager: Some("cargo".to_string()),
build: Some("cargo build".to_string()),
test: Some("cargo test".to_string()),
lint: None,
format: None,
install: None,
}),
}];
let manifest = generate_manifest(&repos, &ManifestGenerationOptions::default());
let ws = manifest.workspace.as_ref().unwrap();
let scripts = ws.scripts.as_ref().unwrap();
assert!(!scripts.contains_key("build-all"));
assert!(!scripts.contains_key("test-all"));
assert!(scripts.contains_key("build-app"));
assert!(scripts.contains_key("test-app"));
let agent = ws.agent.as_ref().unwrap();
let workflows = agent.workflows.as_ref().unwrap();
assert_eq!(workflows["build"], "gr run build-app");
assert_eq!(workflows["test"], "gr run test-app");
}
#[test]
fn test_generate_manifest_roundtrip() {
use crate::core::detect::DetectedToolchain;
let repos = vec![DiscoveredRepo {
name: "myrepo".to_string(),
path: "./myrepo".to_string(),
absolute_path: PathBuf::from("/tmp/myrepo"),
url: Some("git@github.com:org/myrepo.git".to_string()),
default_branch: "main".to_string(),
toolchain: Some(DetectedToolchain {
language: "python".to_string(),
package_manager: Some("uv".to_string()),
build: None,
test: Some("pytest".to_string()),
lint: Some("ruff check .".to_string()),
format: Some("ruff format .".to_string()),
install: Some("uv sync".to_string()),
}),
}];
let manifest = generate_manifest(&repos, &ManifestGenerationOptions::default());
let yaml = manifest_to_yaml(&manifest).unwrap();
let parsed = Manifest::parse(&yaml).unwrap();
assert_eq!(parsed.version, 2);
assert_eq!(parsed.repos.len(), 1);
assert!(parsed.repos.contains_key("myrepo"));
let agent = parsed.repos["myrepo"].agent.as_ref().unwrap();
assert_eq!(agent.language.as_deref(), Some("python"));
}
#[test]
fn test_generate_manifest_post_sync_filtering() {
use crate::core::detect::DetectedToolchain;
let repos = vec![
DiscoveredRepo {
name: "a".to_string(),
path: "./a".to_string(),
absolute_path: PathBuf::from("/tmp/a"),
url: Some("git@github.com:org/a.git".to_string()),
default_branch: "main".to_string(),
toolchain: Some(DetectedToolchain {
language: "typescript".to_string(),
package_manager: Some("npm".to_string()),
build: Some("npm run build".to_string()),
test: Some("npm test".to_string()),
lint: None,
format: None,
install: Some("npm install".to_string()),
}),
},
DiscoveredRepo {
name: "b".to_string(),
path: "./b".to_string(),
absolute_path: PathBuf::from("/tmp/b"),
url: Some("git@github.com:org/b.git".to_string()),
default_branch: "main".to_string(),
toolchain: Some(DetectedToolchain {
language: "ruby".to_string(),
package_manager: Some("bundler".to_string()),
build: None,
test: Some("bundle exec rspec".to_string()),
lint: None,
format: None,
install: Some("bundle install".to_string()),
}),
},
];
let options = ManifestGenerationOptions {
include_post_sync_hooks: true,
agent_targets: vec![],
post_sync_repos: Some(vec!["a".to_string()]),
};
let manifest = generate_manifest(&repos, &options);
let hooks = manifest.workspace.as_ref().unwrap().hooks.as_ref().unwrap();
let post_sync = hooks.post_sync.as_ref().unwrap();
assert_eq!(post_sync.len(), 1);
assert_eq!(post_sync[0].command, "npm install");
}
#[test]
fn test_detect_github_platform() {
let repos = vec![
DiscoveredRepo {
name: "frontend".to_string(),
path: "./frontend".to_string(),
absolute_path: PathBuf::from("/tmp/frontend"),
url: Some("git@github.com:myorg/frontend.git".to_string()),
default_branch: "main".to_string(),
toolchain: None,
},
DiscoveredRepo {
name: "backend".to_string(),
path: "./backend".to_string(),
absolute_path: PathBuf::from("/tmp/backend"),
url: Some("git@github.com:myorg/backend.git".to_string()),
default_branch: "main".to_string(),
toolchain: None,
},
];
let result = detect_common_platform(&repos);
assert!(result.is_some());
let detected = result.unwrap();
assert_eq!(detected.platform, PlatformType::GitHub);
assert_eq!(detected.owner, "myorg");
assert_eq!(detected.confidence, 1.0);
}
#[test]
fn test_detect_azure_platform() {
let repos = vec![
DiscoveredRepo {
name: "app".to_string(),
path: "./app".to_string(),
absolute_path: PathBuf::from("/tmp/app"),
url: Some("git@ssh.dev.azure.com:v3/myorg/myproject/app".to_string()),
default_branch: "main".to_string(),
toolchain: None,
},
DiscoveredRepo {
name: "lib".to_string(),
path: "./lib".to_string(),
absolute_path: PathBuf::from("/tmp/lib"),
url: Some("https://dev.azure.com/myorg/myproject/_git/lib".to_string()),
default_branch: "main".to_string(),
toolchain: None,
},
];
let result = detect_common_platform(&repos);
assert!(result.is_some());
let detected = result.unwrap();
assert_eq!(detected.platform, PlatformType::AzureDevOps);
assert_eq!(detected.owner, "myorg/myproject");
}
#[test]
fn test_detect_gitlab_platform() {
let repos = vec![
DiscoveredRepo {
name: "frontend".to_string(),
path: "./frontend".to_string(),
absolute_path: PathBuf::from("/tmp/frontend"),
url: Some("git@gitlab.com:mygroup/frontend.git".to_string()),
default_branch: "main".to_string(),
toolchain: None,
},
DiscoveredRepo {
name: "backend".to_string(),
path: "./backend".to_string(),
absolute_path: PathBuf::from("/tmp/backend"),
url: Some("https://gitlab.com/mygroup/backend.git".to_string()),
default_branch: "main".to_string(),
toolchain: None,
},
];
let result = detect_common_platform(&repos);
assert!(result.is_some());
let detected = result.unwrap();
assert_eq!(detected.platform, PlatformType::GitLab);
assert_eq!(detected.owner, "mygroup");
}
#[test]
fn test_detect_no_remotes() {
let repos = vec![
DiscoveredRepo {
name: "local1".to_string(),
path: "./local1".to_string(),
absolute_path: PathBuf::from("/tmp/local1"),
url: None,
default_branch: "main".to_string(),
toolchain: None,
},
DiscoveredRepo {
name: "local2".to_string(),
path: "./local2".to_string(),
absolute_path: PathBuf::from("/tmp/local2"),
url: None,
default_branch: "main".to_string(),
toolchain: None,
},
];
let result = detect_common_platform(&repos);
assert!(result.is_none());
}
#[test]
fn test_detect_mixed_platforms() {
let repos = vec![
DiscoveredRepo {
name: "gh1".to_string(),
path: "./gh1".to_string(),
absolute_path: PathBuf::from("/tmp/gh1"),
url: Some("git@github.com:org1/gh1.git".to_string()),
default_branch: "main".to_string(),
toolchain: None,
},
DiscoveredRepo {
name: "gh2".to_string(),
path: "./gh2".to_string(),
absolute_path: PathBuf::from("/tmp/gh2"),
url: Some("git@github.com:org1/gh2.git".to_string()),
default_branch: "main".to_string(),
toolchain: None,
},
DiscoveredRepo {
name: "gl1".to_string(),
path: "./gl1".to_string(),
absolute_path: PathBuf::from("/tmp/gl1"),
url: Some("git@gitlab.com:org2/gl1.git".to_string()),
default_branch: "main".to_string(),
toolchain: None,
},
];
let result = detect_common_platform(&repos);
assert!(result.is_some());
let detected = result.unwrap();
assert_eq!(detected.platform, PlatformType::GitHub);
assert_eq!(detected.owner, "org1");
assert!((detected.confidence - 0.666).abs() < 0.01);
}
#[test]
fn test_suggest_manifest_url_github() {
let url = suggest_manifest_url(PlatformType::GitHub, "myorg", "workspace-manifest");
assert_eq!(url, "git@github.com:myorg/workspace-manifest.git");
}
#[test]
fn test_suggest_manifest_url_gitlab() {
let url = suggest_manifest_url(PlatformType::GitLab, "mygroup", "workspace-manifest");
assert_eq!(url, "git@gitlab.com:mygroup/workspace-manifest.git");
}
#[test]
fn test_suggest_manifest_url_azure() {
let url = suggest_manifest_url(
PlatformType::AzureDevOps,
"myorg/myproject",
"workspace-manifest",
);
assert_eq!(
url,
"git@ssh.dev.azure.com:v3/myorg/myproject/workspace-manifest.git"
);
}
#[test]
fn test_suggest_manifest_https_url_github() {
let url = suggest_manifest_https_url(PlatformType::GitHub, "myorg", "workspace-manifest");
assert_eq!(url, "https://github.com/myorg/workspace-manifest.git");
}
#[test]
fn test_suggest_manifest_https_url_azure() {
let url = suggest_manifest_https_url(
PlatformType::AzureDevOps,
"myorg/myproject",
"workspace-manifest",
);
assert_eq!(
url,
"https://dev.azure.com/myorg/myproject/_git/workspace-manifest"
);
}
fn setup_git_repo(dir: &std::path::Path) -> Repository {
let repo = Repository::init(dir).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
{
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
}
repo
}
#[test]
fn test_detect_default_branch_with_origin_head() {
let tmp = TempDir::new().unwrap();
let origin_dir = tmp.path().join("origin");
std::fs::create_dir_all(&origin_dir).unwrap();
let origin = Repository::init_bare(&origin_dir).unwrap();
origin.set_head("refs/heads/main").unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = origin.treebuilder(None).unwrap().write().unwrap();
{
let tree = origin.find_tree(tree_id).unwrap();
origin
.commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
.unwrap();
}
let clone_dir = tmp.path().join("clone");
let repo = Repository::clone(origin_dir.to_str().unwrap(), &clone_dir).unwrap();
let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat/something", &head_commit, false).unwrap();
repo.set_head("refs/heads/feat/something").unwrap();
let result = detect_default_branch(&repo).unwrap();
assert_eq!(result, "main");
}
#[test]
fn test_detect_default_branch_remote_tracking_main() {
let tmp = TempDir::new().unwrap();
let repo = setup_git_repo(tmp.path());
let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
repo.reference("refs/remotes/origin/main", head_commit.id(), true, "test")
.unwrap();
let mut branch = repo
.find_branch("master", git2::BranchType::Local)
.or_else(|_| repo.find_branch("main", git2::BranchType::Local))
.unwrap();
branch.rename("feat/work", false).unwrap();
let result = detect_default_branch(&repo).unwrap();
assert_eq!(result, "main");
}
#[test]
fn test_detect_default_branch_remote_tracking_master() {
let tmp = TempDir::new().unwrap();
let repo = setup_git_repo(tmp.path());
let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
repo.reference("refs/remotes/origin/master", head_commit.id(), true, "test")
.unwrap();
let mut branch = repo
.find_branch("master", git2::BranchType::Local)
.or_else(|_| repo.find_branch("main", git2::BranchType::Local))
.unwrap();
branch.rename("feat/work", false).unwrap();
let result = detect_default_branch(&repo).unwrap();
assert_eq!(result, "master");
}
#[test]
fn test_detect_default_branch_local_main_only() {
let tmp = TempDir::new().unwrap();
let repo = setup_git_repo(tmp.path());
let head_commit = repo.head().unwrap().peel_to_commit().unwrap();
if repo.find_branch("main", git2::BranchType::Local).is_err() {
repo.branch("main", &head_commit, false).unwrap();
}
repo.branch("feat/test", &head_commit, false).unwrap();
repo.set_head("refs/heads/feat/test").unwrap();
let result = detect_default_branch(&repo).unwrap();
assert!(result == "main" || result == "master");
}
#[test]
fn test_detect_default_branch_empty_repo() {
let tmp = TempDir::new().unwrap();
let _repo = Repository::init(tmp.path()).unwrap();
let repo = Repository::open(tmp.path()).unwrap();
let result = detect_default_branch(&repo).unwrap();
assert_eq!(result, "main"); }
}