3 Commits
v0.4 ... v0.5

Author SHA1 Message Date
5fc739be23 Allow giving API key via env var or CLI args. Gemini 3.0
All checks were successful
Cargo Build & Test / Rust project - latest (1.90) (push) Successful in 2m15s
Prompt
```
Nice! Ok, last change. Can you allow specifying the API key via a CLI
arg, and via an environment variable? Also, have the env var use the
same naming style as for "GITEA_MIRROR_CONFIG_FILEPATH".
```
2025-11-18 18:31:42 -05:00
8f142e07ba Add a flag to not run deletes, meaning non destructive. Gemini 3.0
Prompt;
```
Great! Now can you add a new flag which says to not delete projects.
Basically, I want an option to run the tool in a non destructive
fashion.
```
2025-11-18 18:21:40 -05:00
89d273c38e Add a terraform style plan and allow deleting projects (Gemini 3.0)
Prompt;
```
Can you add a feature such that the tool will list all projects to add
(including renames), and after asking the user for a confirmation to the
deletion? Also add a flag for "no-confirm", which applies the plan
without asking for confirmation. This is basically a dry-run but the
user implicitly saying no for if they want to continue.

Can you also add a feature that the tool will also query gitea if any
projects exist on gitea when they aren't in the toml file, and after
confirming with the user of the plan (which projects to add and remove),
will delete those projects on gitea? Lastly, for the dry run, do the
query of files to be deleted but don't actually delete the files.
```

```
To the execution plan output where you say what projects will be added
and deleted, can you also add what projects  already exist and therefore
have no action needed?
```
2025-11-18 18:16:12 -05:00

View File

@@ -1,242 +1,350 @@
use clap::Parser; use clap::Parser;
use serde::Deserialize; use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::{Level, error, info, instrument, warn}; use tracing::{Level, error, info, instrument, warn};
use tracing_subscriber; use tracing_subscriber;
// Represents the command-line arguments.
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(name = "gitea-mirror")] #[command(name = "gitea-mirror")]
#[command( #[command(about = "Syncs Git repositories to Gitea based on a TOML config.")]
about = "Ensures Git repositories are mirrored to Gitea, generated with Gemini 2.5 Web Canvas"
)]
#[clap(author, version, about, long_about = None)]
struct Args { struct Args {
/// Path to the TOML configuration file. /// Path to the TOML configuration file.
#[clap(short, long, value_parser, env = "GITEA_MIRROR_CONFIG_FILEPATH")] #[clap(short, long, value_parser, env = "GITEA_MIRROR_CONFIG_FILEPATH")]
config: PathBuf, config: PathBuf,
/// Perform a dry run without creating any migrations. /// Gitea API Key.
#[clap(short, long, env = "GITEA_MIRROR_API_KEY")]
api_key: Option<String>,
/// Calculate the plan but do not execute API calls.
#[clap(short, long, default_value_t = false)] #[clap(short, long, default_value_t = false)]
dry_run: bool, dry_run: bool,
/// Skip the interactive confirmation prompt.
#[clap(long, default_value_t = false)]
no_confirm: bool,
/// Do not delete repositories from Gitea.
#[clap(long, default_value_t = false)]
no_delete: bool,
} }
// Represents a single repository entry in the config file.
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
struct RepoConfig { struct RepoConfig {
url: String, url: String,
rename: Option<String>, rename: Option<String>,
} }
// Represents a single organization entry in the config file.
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
struct OrgConfig { struct OrgConfig {
url: String, url: String,
api_key: Option<String>, api_key: Option<String>,
} }
// Represents the main structure of the TOML configuration file.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
struct Config { struct Config {
gitea_url: String, gitea_url: String,
api_key: String, api_key: Option<String>,
repos: Option<Vec<RepoConfig>>, repos: Option<Vec<RepoConfig>>,
organizations: Option<Vec<OrgConfig>>, organizations: Option<Vec<OrgConfig>>,
repo_owner: Option<String>, // Optional owner username/org for all migrated repos repo_owner: Option<String>,
} }
// Represents the payload for creating a migration in Gitea.
#[derive(serde::Serialize, Debug)] #[derive(serde::Serialize, Debug)]
struct MigrateRepoPayload<'a> { struct MigrateRepoPayload<'a> {
clone_addr: &'a str, clone_addr: &'a str,
repo_name: &'a str, repo_name: &'a str,
repo_owner: &'a str, // Username or organization name repo_owner: &'a str,
mirror: bool, mirror: bool,
private: bool, private: bool,
description: &'a str, description: &'a str,
} }
// Represents a user as returned by the Gitea API.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
struct GiteaUser { struct GiteaUser {
login: String, login: String,
} }
/// Entry point of the application.
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize the tracing subscriber for logging.
tracing_subscriber::fmt().with_max_level(Level::INFO).init(); tracing_subscriber::fmt().with_max_level(Level::INFO).init();
// Parse command-line arguments or get config path from environment variable.
let args = Args::parse(); let args = Args::parse();
info!("Starting Gitea mirror process. Dry run: {}", args.dry_run);
// Read and parse the configuration file.
let config = load_config(&args.config)?; let config = load_config(&args.config)?;
let http_client = reqwest::Client::new(); let http_client = reqwest::Client::new();
// Determine the owner (either from repo_owner or authenticated user) // Resolve API Key: CLI/Env > Config File
let final_api_key = args
.api_key
.or(config.api_key.clone())
.ok_or("API Key must be provided via --api-key, GITEA_MIRROR_API_KEY, or config file.")?;
// 1. Determine Target Owner
let owner_name = if let Some(owner) = &config.repo_owner { let owner_name = if let Some(owner) = &config.repo_owner {
info!("Using specified repo_owner: {}", owner);
owner.clone() owner.clone()
} else { } else {
info!("No repo_owner specified, fetching authenticated user"); get_authenticated_username(&http_client, &config.gitea_url, &final_api_key).await?
get_authenticated_username(&http_client, &config.gitea_url, &config.api_key).await?
}; };
info!("Target Owner: {}", owner_name);
info!("Using owner '{}' for all migrated repositories", owner_name); // 2. Build 'Desired' State (Map<RepoName, CloneUrl>)
info!("Resolving desired state from configuration...");
let mut desired_repos: HashMap<String, String> = HashMap::new();
// Process repositories from the static list. // 2a. Static Repos
if let Some(repos) = &config.repos { if let Some(repos) = &config.repos {
for repo_config in repos { for r in repos {
process_repo( let name = r
&repo_config.url, .rename
repo_config.rename.as_deref(), .as_deref()
&owner_name, .or_else(|| extract_repo_name(&r.url))
&http_client, .ok_or_else(|| format!("Invalid URL: {}", r.url))?;
&config, desired_repos.insert(name.to_string(), r.url.clone());
args.dry_run,
)
.await?;
} }
} }
// Process repositories from the organizations/users list. // 2b. Organization Repos
if let Some(org_configs) = &config.organizations { if let Some(orgs) = &config.organizations {
for org_config in org_configs { for org in orgs {
info!( info!("Fetching repos from source: {}", org.url);
"Fetching repositories from organization: {}", let urls =
org_config.url fetch_external_org_repos(&http_client, &org.url, org.api_key.as_deref()).await?;
); for url in urls {
match fetch_org_repos(&http_client, &org_config.url, org_config.api_key.as_deref()) if let Some(name) = extract_repo_name(&url) {
.await desired_repos.insert(name.to_string(), url);
{
Ok(repo_urls) => {
info!(
"Found {} repositories for {}",
repo_urls.len(),
org_config.url
);
for url in repo_urls {
process_repo(
&url,
None, // No rename support for orgs
&owner_name,
&http_client,
&config,
args.dry_run,
)
.await?;
}
} }
Err(e) => error!("Failed to fetch repos from {}: {}", org_config.url, e),
} }
} }
} }
info!("Gitea mirror process completed."); // 3. Build 'Current' State (Set<RepoName>)
info!("Fetching existing repositories from Gitea ({})", owner_name);
let existing_repos =
fetch_all_target_repos(&http_client, &config.gitea_url, &final_api_key, &owner_name)
.await?;
let existing_set: HashSet<String> = existing_repos.into_iter().collect();
// 4. Calculate Diff
let mut to_add: Vec<(String, String)> = desired_repos
.iter()
.filter(|(name, _)| !existing_set.contains(*name))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
// Sort for consistent output
to_add.sort_by(|a, b| a.0.cmp(&b.0));
let mut to_delete: Vec<String> = existing_set
.iter()
.filter(|name| !desired_repos.contains_key(*name))
.cloned()
.collect();
to_delete.sort();
let mut to_keep: Vec<String> = desired_repos
.keys()
.filter(|name| existing_set.contains(*name))
.cloned()
.collect();
to_keep.sort();
// 5. Present Plan
println!("\n--- Execution Plan ---");
for name in &to_keep {
println!(" [=] KEEP: {}", name);
}
for (name, url) in &to_add {
println!(" [+] ADD: {} (Source: {})", name, url);
}
for name in &to_delete {
if args.no_delete {
println!(" [~] SKIP DELETE: {} (--no-delete active)", name);
} else {
println!(" [-] DELETE: {}", name);
}
}
println!("----------------------");
if args.no_delete {
println!(
"Summary: {} to add, {} to delete (SKIPPED), {} unchanged.",
to_add.len(),
to_delete.len(),
to_keep.len()
);
} else {
println!(
"Summary: {} to add, {} to delete, {} unchanged.",
to_add.len(),
to_delete.len(),
to_keep.len()
);
}
// If nothing to add, and (deletes are empty OR we are skipping deletes), then done.
if to_add.is_empty() && (to_delete.is_empty() || args.no_delete) {
info!("Sync complete. No changes to apply.");
return Ok(());
}
// 6. Confirmation / Dry Run
if args.dry_run {
info!("Dry run enabled. Exiting without changes.");
return Ok(());
}
if !args.no_confirm {
print!("\nProceed with these changes? [y/N]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
info!("Aborted by user.");
return Ok(());
}
}
// 7. Execute
// Additions
for (name, url) in to_add {
info!("Migrating {}...", name);
let payload = MigrateRepoPayload {
clone_addr: &url,
repo_name: &name,
repo_owner: &owner_name,
mirror: true,
private: false,
description: "Mirrored via gitea-mirror",
};
match create_migration(&http_client, &config.gitea_url, &final_api_key, &payload).await {
Ok(_) => info!("Successfully migrated {}", name),
Err(e) => error!("Failed to migrate {}: {}", name, e),
}
}
// Deletions
if !args.no_delete {
for name in to_delete {
info!("Deleting {}...", name);
match delete_repo(
&http_client,
&config.gitea_url,
&final_api_key,
&owner_name,
&name,
)
.await
{
Ok(_) => info!("Successfully deleted {}", name),
Err(e) => error!("Failed to delete {}: {}", name, e),
}
}
} else if !to_delete.is_empty() {
info!("Skipping deletions due to --no-delete flag.");
}
info!("Process completed.");
Ok(()) Ok(())
} }
/// Loads and parses the TOML configuration file. // --- Helpers ---
#[instrument(skip(path))] #[instrument(skip(path))]
fn load_config(path: &Path) -> Result<Config, Box<dyn std::error::Error>> { fn load_config(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
info!("Loading configuration from: {:?}", path);
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?; let config: Config = toml::from_str(&content)?;
Ok(config) Ok(config)
} }
/// Fetches the authenticated user's login name from Gitea. fn extract_repo_name(url: &str) -> Option<&str> {
#[instrument(skip(http_client, gitea_url, api_key))] url.split('/').last().map(|s| s.trim_end_matches(".git"))
}
// --- API Calls ---
async fn get_authenticated_username( async fn get_authenticated_username(
http_client: &reqwest::Client, client: &reqwest::Client,
gitea_url: &str, base_url: &str,
api_key: &str, api_key: &str,
) -> Result<String, reqwest::Error> { ) -> Result<String, reqwest::Error> {
let url = format!("{}/api/v1/user", gitea_url); let url = format!("{}/api/v1/user", base_url);
let user: GiteaUser = http_client let user: GiteaUser = client
.get(&url) .get(&url)
.header("Authorization", format!("token {}", api_key)) .bearer_auth(api_key)
.send() .send()
.await? .await?
.error_for_status()? .error_for_status()?
.json() .json()
.await?; .await?;
info!("Authenticated as user: {}", user.login);
Ok(user.login) Ok(user.login)
} }
/// Checks if a repository already exists in Gitea for the user. /// Fetches ALL repos for the target owner on the Gitea instance.
#[instrument(skip(http_client, gitea_url, api_key))] async fn fetch_all_target_repos(
async fn repo_exists( client: &reqwest::Client,
http_client: &reqwest::Client,
gitea_url: &str, gitea_url: &str,
api_key: &str, api_key: &str,
repo_name: &str, owner: &str,
) -> Result<bool, reqwest::Error> { ) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let url = format!("{}/api/v1/repos/search", gitea_url); let mut names = Vec::new();
let response: serde_json::Value = http_client let mut page = 1;
.get(&url) let base_url = format!("{}/api/v1/repos/search", gitea_url);
.query(&[("q", repo_name), ("limit", "1")])
.header("Authorization", format!("token {}", api_key)) loop {
.send() let params = [
.await? ("owner", owner),
.error_for_status()? ("limit", "50"),
.json() ("page", &page.to_string()),
.await?; ];
let res = client
.get(&base_url)
.bearer_auth(api_key)
.query(&params)
.send()
.await?
.error_for_status()?;
let json: serde_json::Value = res.json().await?;
let data = json
.get("data")
.and_then(|d| d.as_array())
.ok_or("Invalid API response")?;
if data.is_empty() {
break;
}
if let Some(data) = response.get("data").and_then(|d| d.as_array()) {
for repo in data { for repo in data {
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) { if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
if name.eq_ignore_ascii_case(repo_name) { names.push(name.to_string());
return Ok(true);
}
} }
} }
page += 1;
} }
Ok(names)
Ok(false)
} }
/// Creates a mirror migration in Gitea. /// Fetches clone URLs from external source (GitHub/Gitea).
#[instrument(skip(http_client, config, payload))] async fn fetch_external_org_repos(
async fn create_migration( client: &reqwest::Client,
http_client: &reqwest::Client,
config: &Config,
payload: &MigrateRepoPayload<'_>,
) -> Result<(), reqwest::Error> {
let url = format!("{}/api/v1/repos/migrate", config.gitea_url);
http_client
.post(&url)
.header("Authorization", format!("token {}", config.api_key))
.json(payload)
.send()
.await?
.error_for_status()?;
Ok(())
}
/// Fetches all repository clone URLs from a given Gitea/GitHub organization/user page.
#[instrument(skip(http_client, api_key))]
async fn fetch_org_repos(
http_client: &reqwest::Client,
org_url: &str, org_url: &str,
api_key: Option<&str>, api_key: Option<&str>,
) -> Result<Vec<String>, Box<dyn std::error::Error>> { ) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// This is a simplified fetcher. It assumes Gitea API compatibility.
// For GitHub, you might need a different base URL and auth method.
let api_url = if org_url.contains("github.com") { let api_url = if org_url.contains("github.com") {
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect(); let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
let user_or_org = parts.last().ok_or("Invalid GitHub URL")?; let user_or_org = parts.last().ok_or("Invalid GitHub URL")?;
format!("https://api.github.com/users/{}/repos", user_or_org) format!("https://api.github.com/users/{}/repos", user_or_org)
} else { } else {
// Assuming Gitea-like URL structure // Assuming Gitea
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect(); let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
let user_or_org = parts.last().ok_or("Invalid Gitea URL")?; let user_or_org = parts.last().ok_or("Invalid Gitea URL")?;
// Heuristic to find API endpoint from web URL
format!( format!(
"{}s/{}/repos", "{}s/{}/repos",
org_url.replace(user_or_org, &format!("api/v1/user")), org_url.replace(user_or_org, &format!("api/v1/user")),
@@ -244,35 +352,29 @@ async fn fetch_org_repos(
) )
}; };
info!("Querying API endpoint: {}", api_url); let mut repos = Vec::new();
let mut repos: Vec<String> = Vec::new();
let mut page = 1; let mut page = 1;
loop { loop {
let mut request_builder = http_client let mut req = client
.get(&api_url) .get(&api_url)
.query(&[("page", page.to_string())]) .query(&[("page", page.to_string())])
// For GitHub, a User-Agent is required. .header("User-Agent", "gitea-mirror-rust");
.header("User-Agent", "gitea-mirror-rust-client");
if let Some(key) = api_key { if let Some(key) = api_key {
request_builder = request_builder.header("Authorization", format!("token {}", key)); req = req.bearer_auth(key);
} }
let response: Vec<serde_json::Value> = request_builder let res = req.send().await?.error_for_status()?;
.send() let json: Vec<serde_json::Value> = res.json().await?;
.await?
.error_for_status()?
.json()
.await?;
if response.is_empty() { if json.is_empty() {
break; // No more pages break;
} }
for repo in response { for repo in json {
if let Some(clone_url) = repo.get("clone_url").and_then(|u| u.as_str()) { if let Some(url) = repo.get("clone_url").and_then(|u| u.as_str()) {
repos.push(clone_url.to_string()); repos.push(url.to_string());
} }
} }
page += 1; page += 1;
@@ -281,53 +383,36 @@ async fn fetch_org_repos(
Ok(repos) Ok(repos)
} }
/// Core logic to process a single repository. async fn create_migration(
#[instrument(skip(owner_name, http_client, config, dry_run))] client: &reqwest::Client,
async fn process_repo( gitea_url: &str,
repo_url: &str, api_key: &str,
rename: Option<&str>, payload: &MigrateRepoPayload<'_>,
owner_name: &str, ) -> Result<(), reqwest::Error> {
http_client: &reqwest::Client, let url = format!("{}/api/v1/repos/migrate", gitea_url);
config: &Config, client
dry_run: bool, .post(&url)
) -> Result<(), Box<dyn std::error::Error>> { .bearer_auth(api_key)
let repo_name = match rename { .json(payload)
Some(name) => name, .send()
None => extract_repo_name(repo_url).ok_or("Could not extract repo name from URL")?, .await?
}; .error_for_status()?;
info!("Processing repo '{}' -> '{}'", repo_url, repo_name);
if repo_exists(http_client, &config.gitea_url, &config.api_key, repo_name).await? {
info!("Repo '{}' already exists. Skipping.", repo_name);
} else {
warn!("Repo '{}' does not exist. Migration needed.", repo_name);
if !dry_run {
info!("Initiating migration for '{}'...", repo_name);
let payload = MigrateRepoPayload {
clone_addr: repo_url,
repo_name,
repo_owner: owner_name,
mirror: true,
private: false, // Defaulting to public, change if needed
description: "",
};
if let Err(e) = create_migration(http_client, config, &payload).await {
error!("Failed to create migration for '{}': {}", repo_name, e);
} else {
info!("Successfully started migration for '{}'.", repo_name);
}
} else {
info!(
"Dry run enabled. Skipping actual migration for '{}'.",
repo_name
);
}
}
Ok(()) Ok(())
} }
/// Extracts a repository name from a git URL (e.g., "https://.../repo.git" -> "repo"). async fn delete_repo(
fn extract_repo_name(url: &str) -> Option<&str> { client: &reqwest::Client,
url.split('/').last().map(|s| s.trim_end_matches(".git")) gitea_url: &str,
api_key: &str,
owner: &str,
repo_name: &str,
) -> Result<(), reqwest::Error> {
let url = format!("{}/api/v1/repos/{}/{}", gitea_url, owner, repo_name);
client
.delete(&url)
.bearer_auth(api_key)
.send()
.await?
.error_for_status()?;
Ok(())
} }