Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
568e5ece49
|
|||
|
4c2086e2b4
|
|||
|
f13906d762
|
|||
|
d8fd1ac57d
|
|||
|
eeeb42b48b
|
|||
|
5fc739be23
|
|||
|
8f142e07ba
|
|||
|
89d273c38e
|
|||
|
ae347d7506
|
|||
|
fdb7cf7a4a
|
|||
|
3497cbaa6e
|
|||
|
129d67bc8b
|
|||
|
f732535db2
|
|||
|
121387dbd2
|
|||
|
0292577ff8
|
|||
|
9e63a0e3a8
|
1189
Cargo.lock
generated
1189
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,10 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
reqwest = { version = "0.12.23", features = ["json"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
reqwest = { version = "0.13", features = ["json", "query"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tokio = { version = "1.35", features = ["full"] }
|
toml = "0.9"
|
||||||
toml = "0.9.7"
|
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
11
GEMINI.md
Normal file
11
GEMINI.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Project: Tool to help handle mirroring of projects to Gitea
|
||||||
|
|
||||||
|
## General Instructions
|
||||||
|
|
||||||
|
Look at the `vibe_coding_log` directory (especially the `README.md` file) where you will track conversations for audit/archival purposes.
|
||||||
|
|
||||||
|
Any changes you do should be as minimal as possible to the underlying code (to make code review easier). Also, follow coding styles that already exist and do not deviate from them.
|
||||||
|
|
||||||
|
Always run `cargo check` and `cargo test` and `cargo clippy` to maintain general project quality.
|
||||||
|
|
||||||
|
When creating commits, look at past commits and try to follow that style (so no smileys).
|
||||||
42
build.rs
Normal file
42
build.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Only re-run if .git state changes
|
||||||
|
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||||
|
println!("cargo:rerun-if-changed=.git/refs/tags");
|
||||||
|
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["describe", "--tags", "--exact-match"])
|
||||||
|
.output();
|
||||||
|
|
||||||
|
let version = if let Ok(output) = output {
|
||||||
|
if output.status.success() {
|
||||||
|
// Exact tag match
|
||||||
|
String::from_utf8(output.stdout).unwrap().trim().to_string()
|
||||||
|
} else {
|
||||||
|
// Not an exact match, construct version string
|
||||||
|
let tag_output = Command::new("git")
|
||||||
|
.args(["describe", "--tags", "--abbrev=0"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute git describe");
|
||||||
|
|
||||||
|
let sha_output = Command::new("git")
|
||||||
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.expect("Failed to execute git rev-parse");
|
||||||
|
|
||||||
|
if tag_output.status.success() && sha_output.status.success() {
|
||||||
|
let tag = String::from_utf8(tag_output.stdout).unwrap().trim().to_string();
|
||||||
|
let sha = String::from_utf8(sha_output.stdout).unwrap().trim().to_string();
|
||||||
|
format!("{}-g{}", tag, sha)
|
||||||
|
} else {
|
||||||
|
// Fallback if git fails or no tags
|
||||||
|
"unknown".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"unknown".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("cargo:rustc-env=GIT_VERSION={}", version);
|
||||||
|
}
|
||||||
21
example.toml
Normal file
21
example.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# The base URL of your Gitea instance
|
||||||
|
gitea_url = "https://gitmirror.hak8or.com"
|
||||||
|
|
||||||
|
# Your Gitea API key (generate one from User Settings -> Applications)
|
||||||
|
api_key = "API_KEY_GOES_HERE"
|
||||||
|
|
||||||
|
# Optional: specify the owner username for all migrated repos
|
||||||
|
# If not specified, uses the user who owns the API key
|
||||||
|
repo_owner = "mirror_org"
|
||||||
|
|
||||||
|
# A list of remote git repositories to mirror.
|
||||||
|
repos = [
|
||||||
|
{ url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
|
||||||
|
{ rename = "cool_rename", url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
|
||||||
|
{ rename = "cool_another_rename", url = "https://gitea.hak8or.com/hak8or/gitea_mirror.git" },
|
||||||
|
{ url = "https://github.com/justcallmekoko/ESP32Marauder" }
|
||||||
|
]
|
||||||
|
|
||||||
|
organizations = [
|
||||||
|
{ url = "https://gitea.hak8or.com/mirrors" },
|
||||||
|
]
|
||||||
522
src/main.rs
522
src/main.rs
@@ -1,193 +1,459 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
|
use serde::Deserialize;
|
||||||
use serde::{Deserialize, Serialize};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::fs;
|
||||||
use tracing::{debug, error, info, warn};
|
use std::io::{self, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tracing::{Level, error, info, instrument, warn};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "gitea-mirror")]
|
#[command(name = "gitea-mirror")]
|
||||||
#[command(about = "Ensures Git repositories are mirrored to Gitea, generated with Claude Opus 4.1")]
|
#[command(version = env!("GIT_VERSION"))]
|
||||||
|
#[command(about = "Syncs Git repositories to Gitea based on a TOML config.")]
|
||||||
struct Args {
|
struct Args {
|
||||||
/// Path to TOML configuration file
|
/// Path to the TOML configuration file.
|
||||||
#[arg(short, long, env = "GITEA_MIRROR_CONFIG_FILEPATH")]
|
#[clap(short, long, value_parser, env = "GITEA_MIRROR_CONFIG_FILEPATH")]
|
||||||
config: PathBuf,
|
config: PathBuf,
|
||||||
|
|
||||||
/// Dry run - check but don't create migrations
|
/// Gitea API Key.
|
||||||
#[arg(short, long, default_value_t = false)]
|
#[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)]
|
||||||
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
struct RepoConfig {
|
||||||
|
url: String,
|
||||||
|
rename: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
struct OrgConfig {
|
||||||
|
url: String,
|
||||||
|
api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
struct Config {
|
struct Config {
|
||||||
gitea_url: String,
|
gitea_url: String,
|
||||||
api_key: String,
|
api_key: Option<String>,
|
||||||
git_urls: Vec<String>,
|
repos: Option<Vec<RepoConfig>>,
|
||||||
|
organizations: Option<Vec<OrgConfig>>,
|
||||||
|
repo_owner: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, Debug)]
|
||||||
|
struct MigrateRepoPayload<'a> {
|
||||||
|
clone_addr: &'a str,
|
||||||
|
repo_name: &'a str,
|
||||||
|
repo_owner: &'a str,
|
||||||
|
mirror: bool,
|
||||||
|
private: bool,
|
||||||
|
description: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
struct Repository {
|
struct GiteaUser {
|
||||||
name: String,
|
login: String,
|
||||||
mirror: bool,
|
|
||||||
original_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct MigrateRepoRequest {
|
|
||||||
clone_addr: String,
|
|
||||||
repo_name: String,
|
|
||||||
mirror: bool,
|
|
||||||
private: bool,
|
|
||||||
description: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt().with_max_level(Level::INFO).init();
|
||||||
|
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
let config = load_config(&args.config)?;
|
||||||
|
let http_client = reqwest::Client::new();
|
||||||
|
|
||||||
// Load configuration
|
// Resolve API Key: CLI/Env > Config File
|
||||||
let config_content = std::fs::read_to_string(&args.config)?;
|
let final_api_key = args
|
||||||
let config: Config = toml::from_str(&config_content)?;
|
.api_key
|
||||||
|
.or(config.api_key.clone())
|
||||||
|
.ok_or("API Key must be provided via --api-key, GITEA_MIRROR_API_KEY, or config file.")?;
|
||||||
|
|
||||||
info!("Starting Gitea mirror sync");
|
// 1. Determine Target Owner
|
||||||
info!("Dry run: {}", args.dry_run);
|
let owner_name = if let Some(owner) = &config.repo_owner {
|
||||||
info!("Gitea URL: {}", config.gitea_url);
|
owner.clone()
|
||||||
info!("Checking {} repositories", config.git_urls.len());
|
} else {
|
||||||
|
get_authenticated_username(&http_client, &config.gitea_url, &final_api_key).await?
|
||||||
|
};
|
||||||
|
info!("Target Owner: {}", owner_name);
|
||||||
|
|
||||||
// Create HTTP client with auth header
|
// 2. Build 'Desired' State (Map<RepoName, CloneUrl>)
|
||||||
let mut headers = HeaderMap::new();
|
info!("Resolving desired state from configuration...");
|
||||||
headers.insert(
|
let mut desired_repos: HashMap<String, String> = HashMap::new();
|
||||||
AUTHORIZATION,
|
let mut seen_names: HashSet<String> = HashSet::new();
|
||||||
HeaderValue::from_str(&format!("token {}", config.api_key))?,
|
let mut has_error = false;
|
||||||
|
|
||||||
|
// 2a. Static Repos
|
||||||
|
if let Some(repos) = &config.repos {
|
||||||
|
for r in repos {
|
||||||
|
let name = r
|
||||||
|
.rename
|
||||||
|
.as_deref()
|
||||||
|
.or_else(|| extract_repo_name(&r.url))
|
||||||
|
.ok_or_else(|| format!("Invalid URL: {}", r.url))?;
|
||||||
|
|
||||||
|
let name_lower = name.to_lowercase();
|
||||||
|
if seen_names.contains(&name_lower) {
|
||||||
|
warn!(
|
||||||
|
"Duplicate repository name detected (case-insensitive): '{}'. URL: {}",
|
||||||
|
name, r.url
|
||||||
);
|
);
|
||||||
let client = reqwest::Client::builder()
|
has_error = true;
|
||||||
.default_headers(headers)
|
continue;
|
||||||
.build()?;
|
}
|
||||||
|
seen_names.insert(name_lower);
|
||||||
|
|
||||||
// Process each Git URL
|
desired_repos.insert(name.to_string(), r.url.clone());
|
||||||
for git_url in &config.git_urls {
|
}
|
||||||
info!("Processing: {}", git_url);
|
}
|
||||||
|
|
||||||
let repo_name = extract_repo_name(git_url);
|
// 2b. Organization Repos
|
||||||
let is_mirrored =
|
if let Some(orgs) = &config.organizations {
|
||||||
check_if_mirrored(&client, &config.gitea_url, git_url, &repo_name).await?;
|
for org in orgs {
|
||||||
|
info!("Fetching repos from source: {}", org.url);
|
||||||
|
let urls =
|
||||||
|
fetch_external_org_repos(&http_client, &org.url, org.api_key.as_deref()).await?;
|
||||||
|
for url in urls {
|
||||||
|
if let Some(name) = extract_repo_name(&url) {
|
||||||
|
let name_lower = name.to_lowercase();
|
||||||
|
if seen_names.contains(&name_lower) {
|
||||||
|
warn!(
|
||||||
|
"Duplicate repository name detected (case-insensitive) from organization import: '{}'. URL: {}",
|
||||||
|
name, url
|
||||||
|
);
|
||||||
|
has_error = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen_names.insert(name_lower);
|
||||||
|
desired_repos.insert(name.to_string(), url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if is_mirrored {
|
if has_error {
|
||||||
info!("✓ Already mirrored: {}", repo_name);
|
return Err("Duplicate repository names detected. Please fix the configuration.".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
} else {
|
||||||
warn!("✗ Not mirrored: {}", repo_name);
|
println!(" [-] DELETE: {}", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("----------------------");
|
||||||
|
|
||||||
if !args.dry_run {
|
if args.no_delete {
|
||||||
info!("Creating migration for: {}", repo_name);
|
println!(
|
||||||
create_migration(&client, &config.gitea_url, git_url, &repo_name).await?;
|
"Summary: {} to add, {} to delete (SKIPPED), {} unchanged.",
|
||||||
info!("✓ Migration created for: {}", repo_name);
|
to_add.len(),
|
||||||
|
to_delete.len(),
|
||||||
|
to_keep.len()
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
info!("[DRY RUN] Would create migration for: {}", repo_name);
|
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(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Gitea mirror sync complete");
|
// 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_repo_name(git_url: &str) -> String {
|
// --- Helpers ---
|
||||||
let url = git_url.trim_end_matches(".git");
|
|
||||||
url.split('/').last().unwrap_or("unknown").to_string()
|
#[instrument(skip(path))]
|
||||||
|
fn load_config(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
|
||||||
|
let content = fs::read_to_string(path)?;
|
||||||
|
let config: Config = toml::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_if_mirrored(
|
fn extract_repo_name(url: &str) -> Option<&str> {
|
||||||
|
url.split('/').next_back().map(|s| s.trim_end_matches(".git"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Calls ---
|
||||||
|
|
||||||
|
async fn get_authenticated_username(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
base_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
) -> Result<String, reqwest::Error> {
|
||||||
|
let url = format!("{}/api/v1/user", base_url);
|
||||||
|
let user: GiteaUser = client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(api_key)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(user.login)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches ALL repos for the target owner on the Gitea instance.
|
||||||
|
async fn fetch_all_target_repos(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
gitea_url: &str,
|
gitea_url: &str,
|
||||||
git_url: &str,
|
api_key: &str,
|
||||||
repo_name: &str,
|
owner: &str,
|
||||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
// Search for repositories by name
|
let org_url = format!("{}/api/v1/orgs/{}/repos", gitea_url, owner);
|
||||||
let search_url = format!("{}/api/v1/repos/search", gitea_url);
|
match fetch_repos_from_endpoint(client, &org_url, api_key).await {
|
||||||
let response = client
|
Ok(repos) => Ok(repos),
|
||||||
.get(&search_url)
|
Err(e) => {
|
||||||
.query(&[("q", repo_name), ("limit", "50")])
|
if e.downcast_ref::<reqwest::Error>()
|
||||||
|
.is_some_and(|r| r.status() == Some(reqwest::StatusCode::NOT_FOUND))
|
||||||
|
{
|
||||||
|
info!("Owner '{}' not found as org, trying as user...", owner);
|
||||||
|
let user_url = format!("{}/api/v1/users/{}/repos", gitea_url, owner);
|
||||||
|
return fetch_repos_from_endpoint(client, &user_url, api_key).await;
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_repos_from_endpoint(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
let mut page = 1;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let params = [("limit", "50"), ("page", &page.to_string())];
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.get(url)
|
||||||
|
.bearer_auth(api_key)
|
||||||
|
.query(¶ms)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let json: serde_json::Value = res.json().await?;
|
||||||
error!("Failed to search repos: {}", response.status());
|
let data = json.as_array().ok_or("Invalid API response")?;
|
||||||
return Ok(false);
|
|
||||||
|
if data.is_empty() {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let search_result: serde_json::Value = response.json().await?;
|
for repo in data {
|
||||||
|
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
|
||||||
if let Some(data) = search_result.get("data").and_then(|d| d.as_array()) {
|
names.push(name.to_string());
|
||||||
for repo_json in data {
|
|
||||||
if let Ok(repo) = serde_json::from_value::<Repository>(repo_json.clone()) {
|
|
||||||
debug!("Found repo: {} (mirror: {})", repo.name, repo.mirror);
|
|
||||||
|
|
||||||
// Check if this is a mirror and matches our URL
|
|
||||||
if repo.mirror {
|
|
||||||
if let Some(original) = &repo.original_url {
|
|
||||||
// Normalize URLs for comparison
|
|
||||||
let normalized_original = normalize_git_url(original);
|
|
||||||
let normalized_target = normalize_git_url(git_url);
|
|
||||||
|
|
||||||
if normalized_original == normalized_target {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
page += 1;
|
||||||
}
|
}
|
||||||
|
Ok(names)
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(false)
|
/// Fetches clone URLs from external source (GitHub/Gitea).
|
||||||
|
async fn fetch_external_org_repos(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
org_url: &str,
|
||||||
|
api_key: Option<&str>,
|
||||||
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
let api_url = if org_url.contains("github.com") {
|
||||||
|
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
|
||||||
|
let user_or_org = parts.last().ok_or("Invalid GitHub URL")?;
|
||||||
|
format!("https://api.github.com/users/{}/repos", user_or_org)
|
||||||
|
} else {
|
||||||
|
// Assuming Gitea
|
||||||
|
let parts: Vec<&str> = org_url.trim_end_matches('/').split('/').collect();
|
||||||
|
let user_or_org = parts.last().ok_or("Invalid Gitea URL")?;
|
||||||
|
// Heuristic to find API endpoint from web URL
|
||||||
|
format!(
|
||||||
|
"{}s/{}/repos",
|
||||||
|
org_url.replace(user_or_org, "api/v1/user"),
|
||||||
|
user_or_org
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut repos = Vec::new();
|
||||||
|
let mut page = 1;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut req = client
|
||||||
|
.get(&api_url)
|
||||||
|
.query(&[("page", page.to_string())])
|
||||||
|
.header("User-Agent", "gitea-mirror-rust");
|
||||||
|
|
||||||
|
if let Some(key) = api_key {
|
||||||
|
req = req.bearer_auth(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_git_url(url: &str) -> String {
|
let res = req.send().await?.error_for_status()?;
|
||||||
let mut normalized = url.to_lowercase();
|
let json: Vec<serde_json::Value> = res.json().await?;
|
||||||
|
|
||||||
// Remove trailing .git
|
if json.is_empty() {
|
||||||
if normalized.ends_with(".git") {
|
break;
|
||||||
normalized = normalized[..normalized.len() - 4].to_string();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert git@ to https://
|
for repo in json {
|
||||||
if normalized.starts_with("git@") {
|
if let Some(url) = repo.get("clone_url").and_then(|u| u.as_str()) {
|
||||||
normalized = normalized.replace("git@", "https://").replace(":", "/");
|
repos.push(url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove protocol variations
|
Ok(repos)
|
||||||
normalized = normalized
|
|
||||||
.replace("https://", "")
|
|
||||||
.replace("http://", "")
|
|
||||||
.replace("git://", "");
|
|
||||||
|
|
||||||
normalized
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_migration(
|
async fn create_migration(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
gitea_url: &str,
|
gitea_url: &str,
|
||||||
git_url: &str,
|
api_key: &str,
|
||||||
repo_name: &str,
|
payload: &MigrateRepoPayload<'_>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), reqwest::Error> {
|
||||||
let migrate_url = format!("{}/api/v1/repos/migrate", gitea_url);
|
let url = format!("{}/api/v1/repos/migrate", gitea_url);
|
||||||
|
client
|
||||||
let request = MigrateRepoRequest {
|
.post(&url)
|
||||||
clone_addr: git_url.to_string(),
|
.bearer_auth(api_key)
|
||||||
repo_name: repo_name.to_string(),
|
.json(payload)
|
||||||
mirror: true,
|
.send()
|
||||||
private: false,
|
.await?
|
||||||
description: format!("Mirror of {}", git_url),
|
.error_for_status()?;
|
||||||
};
|
Ok(())
|
||||||
|
}
|
||||||
let response = client.post(&migrate_url).json(&request).send().await?;
|
|
||||||
|
async fn delete_repo(
|
||||||
if !response.status().is_success() {
|
client: &reqwest::Client,
|
||||||
let status = response.status();
|
gitea_url: &str,
|
||||||
let error_text = response.text().await?;
|
api_key: &str,
|
||||||
error!("Failed to create migration: {} - {}", status, error_text);
|
owner: &str,
|
||||||
return Err(format!("Migration failed: {}", status).into());
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
22
vibe_coding_log/README.md
Normal file
22
vibe_coding_log/README.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Vibe Coding Log
|
||||||
|
|
||||||
|
This directory contains logs of coding sessions with LLM assistants. The purpose is to track the evolution of LLM coding capabilities over time and provide an audit trail of changes made to the codebase.
|
||||||
|
|
||||||
|
## Log Structure
|
||||||
|
|
||||||
|
Each log entry is saved as a Markdown file named with the pattern:
|
||||||
|
`session_<YYYY_MM_DD>_<topic>.md`
|
||||||
|
|
||||||
|
## Instructions for Creating New Entries
|
||||||
|
|
||||||
|
1. **Create a new file**: Use the naming convention above.
|
||||||
|
2. **Record the Session**: Copy the conversation transcript or write a detailed summary of the interaction.
|
||||||
|
3. **Metadata**: Include the following at the top of the file:
|
||||||
|
* **Date**: The date of the session.
|
||||||
|
* **Model**: The name/version of the LLM used (e.g., Gemini 2.5 Flash, GPT-4, etc.).
|
||||||
|
* **Goal**: The primary objective of the session.
|
||||||
|
* **Outcome**: A brief summary of the result.
|
||||||
|
|
||||||
|
## Current Assistant
|
||||||
|
|
||||||
|
The initial logs in this folder were generated by **Gemini** (specifically acting as the Gemini CLI agent).
|
||||||
69
vibe_coding_log/session_2026_01_07_gitea_fix.md
Normal file
69
vibe_coding_log/session_2026_01_07_gitea_fix.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Session Log: Gitea Mirror Fixes
|
||||||
|
|
||||||
|
**Date**: Wednesday, January 7, 2026
|
||||||
|
**Model**: Gemini (CLI Agent)
|
||||||
|
**Goal**: Fix compiler errors after dependency bump and correct Gitea API usage.
|
||||||
|
**Outcome**: Successfully resolved `reqwest` 0.13 breaking changes and fixed logic for fetching repositories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transcript Summary
|
||||||
|
|
||||||
|
### 1. Dependency Update Fix
|
||||||
|
|
||||||
|
**User**: Reported a compiler error after bumping dependencies.
|
||||||
|
**Context**: The project uses `reqwest`. A recent update to `reqwest` 0.13 made the `.query()` method feature-gated.
|
||||||
|
**Error**:
|
||||||
|
```
|
||||||
|
error[E0599]: no method named `query` found for struct `RequestBuilder` in the current scope
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agent Action**:
|
||||||
|
1. Analyzed `Cargo.toml` and build output.
|
||||||
|
2. Identified that `reqwest` 0.13 requires the `query` feature to use the query string builder.
|
||||||
|
3. Updated `Cargo.toml`:
|
||||||
|
```toml
|
||||||
|
reqwest = { version = "0.13", features = ["json", "query"] }
|
||||||
|
```
|
||||||
|
4. Verified with `cargo build`.
|
||||||
|
5. Committed changes: "Fix compilation error by enabling reqwest/query feature".
|
||||||
|
|
||||||
|
### 2. Logic Fix and Refactoring
|
||||||
|
|
||||||
|
**User**: Provided a `git diff` from another machine fixing an issue where Gitea's search API ignored the "owner" field, causing incorrect repo mirroring. Also requested incorporation of the fix.
|
||||||
|
|
||||||
|
**Agent Action**:
|
||||||
|
1. Applied the logic change to `src/main.rs`:
|
||||||
|
* Switched from `/api/v1/repos/search` to `/api/v1/orgs/{owner}/repos`.
|
||||||
|
* Updated response parsing to handle the JSON array directly.
|
||||||
|
2. Addressed `cargo clippy` warnings identified during the process:
|
||||||
|
* Removed unused `tracing_subscriber` import.
|
||||||
|
* Optimized iterator usage: `url.split('/').last()` -> `url.split('/').next_back()`.
|
||||||
|
* Removed redundant `format!` usage.
|
||||||
|
3. Verified with `cargo build` and `cargo clippy`.
|
||||||
|
4. Committed changes: "Fix target repo fetching by using orgs endpoint and address clippy warnings".
|
||||||
|
|
||||||
|
### 3. Verification and Robustness
|
||||||
|
|
||||||
|
**User**: Asked to verify if the logic fix (using `/orgs/{owner}/repos`) was correct via rigorous checking.
|
||||||
|
|
||||||
|
**Analysis**:
|
||||||
|
* Research confirmed that `/repos/search` does not support filtering by `owner` name (only `uid`), validating the need for a change.
|
||||||
|
* However, `/orgs/{owner}/repos` **only** works if the target is an organization. If the target is a User, it would fail.
|
||||||
|
* The correct endpoint for users is `/users/{owner}/repos`.
|
||||||
|
|
||||||
|
**Agent Action**:
|
||||||
|
1. Refactored `fetch_all_target_repos` to use a fallback strategy:
|
||||||
|
* First, try fetching from `/api/v1/orgs/{owner}/repos`.
|
||||||
|
* If the API returns `404 Not Found`, automatically retry with `/api/v1/users/{owner}/repos`.
|
||||||
|
2. Extracted the fetching logic into a helper function `fetch_repos_from_endpoint` to avoid duplication.
|
||||||
|
3. Addressed new `clippy` suggestions regarding `map_or` and `is_some_and`.
|
||||||
|
4. Committed changes: "Implement fallback to user repos endpoint if org not found".
|
||||||
|
|
||||||
|
## Final State
|
||||||
|
|
||||||
|
The codebase now:
|
||||||
|
1. Compiles with the latest dependencies (`reqwest` 0.13).
|
||||||
|
2. Correctly filters repositories by the target owner.
|
||||||
|
3. Robustly handles both Organization and User targets by attempting both endpoints.
|
||||||
|
4. Is free of linting warnings.
|
||||||
23
vibe_coding_log/session_2026_01_10_duplicate_detection.md
Normal file
23
vibe_coding_log/session_2026_01_10_duplicate_detection.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Session 2026-01-10: Duplicate Repository Detection
|
||||||
|
|
||||||
|
* **Date**: 2026-01-10
|
||||||
|
* **Model**: Gemini 2.5 Flash / Gemini 3 Pro Preview
|
||||||
|
* **Goal**: Implement case-insensitive duplication detection for repository names in the configuration.
|
||||||
|
* **Outcome**: Added logic to detect duplicate repository names (case-insensitive) from both static configuration and organization imports. The tool now logs warnings for all detected duplicates and then exits with a fatal error if any duplicates were found.
|
||||||
|
|
||||||
|
## Details
|
||||||
|
|
||||||
|
1. **Duplicate Detection**:
|
||||||
|
* Modified `src/main.rs` to maintain a `HashSet` of lowercased repository names.
|
||||||
|
* Checks both the `repos` list and `organizations` imports.
|
||||||
|
* If a duplicate is found, a `WARN` log is emitted with details (name and URL).
|
||||||
|
* A `has_error` flag is set to true.
|
||||||
|
|
||||||
|
2. **Error Handling**:
|
||||||
|
* After processing all sources, if `has_error` is true, the program returns a fatal error: "Duplicate repository names detected. Please fix the configuration."
|
||||||
|
* This ensures the user sees all conflicts before the program exits.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
* Created a `duplicate_repro.toml` with conflicting names (e.g., `ProjectA` vs `projecta`).
|
||||||
|
* Verified that `cargo run -- --config duplicate_repro.toml --dry-run` correctly outputted warnings for each duplicate and then exited with a non-zero status code and the expected error message.
|
||||||
38
vibe_coding_log/session_2026_01_10_version_flag.md
Normal file
38
vibe_coding_log/session_2026_01_10_version_flag.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Session Log: Add Version Flag
|
||||||
|
|
||||||
|
**Date**: Saturday, January 10, 2026
|
||||||
|
**Model**: Gemini (CLI Agent)
|
||||||
|
**Goal**: Add a `--version` flag that displays the git tag and short SHA.
|
||||||
|
**Outcome**: Implemented dynamic versioning using `build.rs` and `clap`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transcript Summary
|
||||||
|
|
||||||
|
### 1. Version Flag Implementation
|
||||||
|
|
||||||
|
**User**: Requested a `--version` flag that output the latest git tag. If the current commit is not exactly on a tag, it should append the short SHA (e.g., `v0.6-gd8a9fc`).
|
||||||
|
|
||||||
|
**Agent Action**:
|
||||||
|
1. Analyzed `Cargo.toml` and `src/main.rs`.
|
||||||
|
2. Created a `build.rs` script to handle the logic:
|
||||||
|
* Tries `git describe --tags --exact-match`.
|
||||||
|
* If that fails, tries `git describe --tags --abbrev=0` (latest tag) combined with `git rev-parse --short HEAD` (current SHA).
|
||||||
|
* Sets the `GIT_VERSION` environment variable.
|
||||||
|
3. Modified `src/main.rs`:
|
||||||
|
* Updated the `clap` `Args` struct to use `#[command(version = env!("GIT_VERSION"))]`.
|
||||||
|
4. Verified the output using `cargo run -- --version`.
|
||||||
|
|
||||||
|
### 2. Verification
|
||||||
|
|
||||||
|
**User**: Implicitly relied on agent verification.
|
||||||
|
|
||||||
|
**Agent Action**:
|
||||||
|
1. Ran `cargo run -- --version` -> Output: `gitea-mirror v0.6-gf13906d` (correct format).
|
||||||
|
2. Created a temporary tag `v0.7.0-test`, verified output was `gitea-mirror v0.7.0-test`, then deleted the tag.
|
||||||
|
3. Ran `cargo clippy` (clean).
|
||||||
|
4. Committed changes including the new `build.rs` and updated `src/main.rs`.
|
||||||
|
|
||||||
|
## Final State
|
||||||
|
|
||||||
|
The application now supports `gitea-mirror --version` which dynamically reports the git version at build time.
|
||||||
Reference in New Issue
Block a user