16 Commits
v0.1 ... v0.7

Author SHA1 Message Date
568e5ece49 Add case-insensitive duplicate detection for repos
All checks were successful
Cargo Build & Test / Rust project - latest (1.90) (push) Successful in 5m25s
```
│  Agent powering down. Goodbye!
│
│  Interaction Summary
│  Session ID:                 4fe7dbe6-ab78-49d0-9073-f4ed5ee0afb3
│  Tool Calls:                 21 ( ✓ 20 x 1 )
│  Success Rate:               95.2%
│  User Agreement:             95.2% (21 reviewed)
│  Code Changes:               +73 -2
│
│  Performance
│  Wall Time:                  17m 8s
│  Agent Active:               4m 23s
│    » API Time:               2m 35s (59.2%)
│    » Tool Time:              1m 47s (40.8%)
│
│
│  Model Usage                 Reqs   Input Tokens   Cache Reads  Output Tokens
│  ────────────────────────────────────────────────────────────────────────────
│  gemini-2.5-flash-lite          4          6,360             0            261
│  gemini-3-pro-preview          22         80,208       264,075          4,529
│
│  Savings Highlight: 264,075 (75.3%) of input tokens were served from the cache, reducing costs.
```
2026-01-10 14:40:40 -05:00
4c2086e2b4 Add --version flag showing git tag and sha
```
│  Agent powering down. Goodbye!
│
│  Interaction Summary
│  Session ID:                 66751fe9-fcad-4221-a90a-34d84d303807
│  Tool Calls:                 15 ( ✓ 15 x 0 )
│  Success Rate:               100.0%
│  User Agreement:             100.0% (15 reviewed)
│  Code Changes:               +81 -0
│
│  Performance
│  Wall Time:                  9m 51s
│  Agent Active:               2m 50s
│    » API Time:               1m 40s (58.6%)
│    » Tool Time:              1m 10s (41.4%)
│
│
│  Model Usage                 Reqs   Input Tokens   Cache Reads  Output Tokens
│  ────────────────────────────────────────────────────────────────────────────
│  gemini-2.5-flash-lite          2          2,635             0            199
│  gemini-3-pro-preview          15         54,232       163,544          1,828
│
│  Savings Highlight: 163,544 (74.2%) of input tokens were served from the cache, reducing costs.
```
2026-01-10 14:22:49 -05:00
f13906d762 Add a GEMINI.md for vibe coding 2026-01-10 14:11:46 -05:00
d8fd1ac57d Add vibe code logs and fix repo searching (org vs user)
All checks were successful
Cargo Build & Test / Rust project - latest (1.90) (push) Successful in 3m3s
```
│  Agent powering down. Goodbye!
│
│  Interaction Summary
│  Session ID:                 d1261c5c-d812-4036-b57e-d188bdef12c4
│  Tool Calls:                 30 ( ✓ 29 x 1 )
│  Success Rate:               96.7%
│  User Agreement:             100.0% (30 reviewed)
│  Code Changes:               +124 -21
│
│  Performance
│  Wall Time:                  25m 19s
│  Agent Active:               14m 11s
│    » API Time:               5m 37s (39.6%)
│    » Tool Time:              8m 33s (60.4%)
│
│
│  Model Usage                 Reqs   Input Tokens   Cache Reads  Output Tokens
│  ────────────────────────────────────────────────────────────────────────────
│  gemini-2.5-flash-lite          4          8,739             0            371
│  gemini-3-pro-preview          24        218,346     1,111,266          4,682
│  gemini-2.5-flash               6         13,785             0            784
│  gemini-3-flash-preview         8        110,392       328,221          1,703
│
│  Savings Highlight: 1,439,487 (80.4%) of input tokens were served from the cache, reducing costs.
```
2026-01-07 22:24:11 -05:00
eeeb42b48b Bump clap and reqest and all deps to latest 2026-01-07 22:03:10 -05:00
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
ae347d7506 Add repo_owner for where the migrated repos should go to
All checks were successful
Cargo Build & Test / Rust project - latest (1.90) (push) Successful in 2m11s
Generated with Claude 4.5 Sonnet (Gemini 2.5 Pro was stubborn about
wanting to refactor the world even when it was unrelated). Prompt was;
```
Given the attached rust code, and example toml configuration file;
... SNIP ...

Can you add the ability to specify the owner of the migrated repo, such
that if not provided then to use the user who owns the API key, or if
provided then to use said owner? I think gitea refers to that as the
"repo_owner" in it's swagger based API docs? Note, this would be one
configuration for all the repos in the config toml file, not on a
per-repo basis.
```

which didn't work since it tried to fetch a uid which doesn't exist for
organizations, so lets prompt it to fix that and give it a helping hand;
```
Ah shoot, if I am using a migration to a new organization which the
user who owns the API key has permissions to modify, then I am getting
a 401 return code. Did you assume the target will always be a user
rather than also being an organization? Also, keep in mind, I think
giteas migration API wants a user string, rather than a user ID, if
that's the case then I think we can remove the entire
`get_user_id_by_username()` function?
```
2025-10-06 19:17:28 -04:00
fdb7cf7a4a Some human touches (make clearer this' vibe coded, better env vars, etc)
All checks were successful
Cargo Build & Test / Rust project - latest (1.90) (push) Successful in 1m51s
2025-09-22 20:40:17 -04:00
3497cbaa6e Upload example config toml 2025-09-22 20:33:36 -04:00
129d67bc8b Don't use the same API key for other organizations we are pulling from
```EOF
For the organizations list, I am trying use my test instance, but getting the following in the logs;
```
2025-09-23T00:12:38.638052Z  INFO gitea_mirror: Fetching repositories from organization: https://gitea.hak8or.com/mirrors
2025-09-23T00:12:38.638081Z  INFO fetch_org_repos{org_url="https://gitea.hak8or.com/mirrors"}: gitea_mirror: Querying API endpoint: https://gitea.hak8or.com/api/v1/users/mirrors/repos
2025-09-23T00:12:38.653694Z ERROR gitea_mirror: Failed to fetch repos from https://gitea.hak8or.com/mirrors: HTTP status client error (401 Unauthorized) for url (https://gitea.hak8or.com/api/v1/users/mirrors/repos?page=1)
2025-09-23T00:12:38.653713Z  INFO gitea_mirror: Gitea mirror process completed.
```

I don't have a user with that key for the instance. Can you add the ability to provide an api key to each organization entry in the toml config? At the same time, is it possible to get a list of all repos from an organization without needing to use an api key? If so, when no api key is provided, can you use that?
```EOF
2025-09-22 20:29:14 -04:00
f732535db2 Re-create this with the canvas option in Gemini 2.5 Pro web chat
```EOF
Create a very minimal and simple tool written in rust which takes in a list of git URLs, and using the gitea api checks if the remote is already mirrored, and if not, then create a repo migration to gitea. I want to basically create a script which can be used to ensure a list of git repos are mirrord to a gitea server.

 The script should take in some command line arguments for;
  - an option to do a dry run, meaning do the check if the repo has to be mirrord, but do not initiate the actual migration
 - path to a TOML configuration file (also can be supplied via an ENV variable)

 The configuration file would have the following information;
   - an API key to be used when talking to the gitea instance we are migrating to
  - the url of the above gitea instance
  - a list of git URLs including an optional rename of the repo name
  - a list of URLs of another git server (gitea, if the API is the same then github, gitlab, etc) that includes the organization name or username. You would clone all repos under that organization/username. For example "https://github.com/hak8or" would be all repos owned by hak8or.

Example toml file;
```
gitea_url = "https://gitmirror.hak8or.com"

api_key = "api_key_goes_here"

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" },
	{ rename = "rusty_rust", url = "https://github.com/rust-lang/rust.git" },
]
```

Ensure the script is as minimal as possible, do not use libraries if you can avoid them (except clap for CLI arguments, tracing for logging, actix for async and web interactions, reqwest for actual queries, and serde_json for json, or whatever else is commonly used in rust). I will be invoking this tool with a systemd timer.
```EOF
2025-09-22 20:28:35 -04:00
121387dbd2 Making mirrors be public by default (the heck Gemini ...)
All checks were successful
Cargo Build & Test / Rust project - latest (1.90) (push) Successful in 4m14s
2025-09-21 20:07:53 -04:00
0292577ff8 Remove stupid icons ... (friggen LLMs pulling in web dev habits ...) 2025-09-21 20:07:33 -04:00
9e63a0e3a8 Allow a rename for the repo name when ingesting remote URLs
Some checks failed
Cargo Build & Test / Rust project - latest (1.90) (push) Failing after 13m35s
2025-09-21 19:53:24 -04:00
10 changed files with 1328 additions and 651 deletions

1189
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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
View 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" },
]

View File

@@ -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;
);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()?;
// Process each Git URL // 2a. Static Repos
for git_url in &config.git_urls { if let Some(repos) = &config.repos {
info!("Processing: {}", git_url); 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 repo_name = extract_repo_name(git_url); let name_lower = name.to_lowercase();
let is_mirrored = if seen_names.contains(&name_lower) {
check_if_mirrored(&client, &config.gitea_url, git_url, &repo_name).await?; warn!(
"Duplicate repository name detected (case-insensitive): '{}'. URL: {}",
if is_mirrored { name, r.url
info!("✓ Already mirrored: {}", repo_name); );
} else { has_error = true;
warn!("✗ Not mirrored: {}", repo_name); continue;
if !args.dry_run {
info!("Creating migration for: {}", repo_name);
create_migration(&client, &config.gitea_url, git_url, &repo_name).await?;
info!("✓ Migration created for: {}", repo_name);
} else {
info!("[DRY RUN] Would create migration for: {}", repo_name);
} }
seen_names.insert(name_lower);
desired_repos.insert(name.to_string(), r.url.clone());
} }
} }
info!("Gitea mirror sync complete"); // 2b. Organization Repos
Ok(()) if let Some(orgs) = &config.organizations {
} for org in orgs {
info!("Fetching repos from source: {}", org.url);
fn extract_repo_name(git_url: &str) -> String { let urls =
let url = git_url.trim_end_matches(".git"); fetch_external_org_repos(&http_client, &org.url, org.api_key.as_deref()).await?;
url.split('/').last().unwrap_or("unknown").to_string() for url in urls {
} if let Some(name) = extract_repo_name(&url) {
let name_lower = name.to_lowercase();
async fn check_if_mirrored( if seen_names.contains(&name_lower) {
client: &reqwest::Client, warn!(
gitea_url: &str, "Duplicate repository name detected (case-insensitive) from organization import: '{}'. URL: {}",
git_url: &str, name, url
repo_name: &str, );
) -> Result<bool, Box<dyn std::error::Error>> { has_error = true;
// Search for repositories by name continue;
let search_url = format!("{}/api/v1/repos/search", gitea_url);
let response = client
.get(&search_url)
.query(&[("q", repo_name), ("limit", "50")])
.send()
.await?;
if !response.status().is_success() {
error!("Failed to search repos: {}", response.status());
return Ok(false);
}
let search_result: serde_json::Value = response.json().await?;
if let Some(data) = search_result.get("data").and_then(|d| d.as_array()) {
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);
}
} }
seen_names.insert(name_lower);
desired_repos.insert(name.to_string(), url);
} }
} }
} }
} }
Ok(false) if has_error {
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 {
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(())
} }
fn normalize_git_url(url: &str) -> String { // --- Helpers ---
let mut normalized = url.to_lowercase();
// Remove trailing .git #[instrument(skip(path))]
if normalized.ends_with(".git") { fn load_config(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
normalized = normalized[..normalized.len() - 4].to_string(); let content = fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
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,
gitea_url: &str,
api_key: &str,
owner: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let org_url = format!("{}/api/v1/orgs/{}/repos", gitea_url, owner);
match fetch_repos_from_endpoint(client, &org_url, api_key).await {
Ok(repos) => Ok(repos),
Err(e) => {
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(&params)
.send()
.await?
.error_for_status()?;
let json: serde_json::Value = res.json().await?;
let data = json.as_array().ok_or("Invalid API response")?;
if data.is_empty() {
break;
}
for repo in data {
if let Some(name) = repo.get("name").and_then(|n| n.as_str()) {
names.push(name.to_string());
}
}
page += 1;
}
Ok(names)
}
/// 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);
}
let res = req.send().await?.error_for_status()?;
let json: Vec<serde_json::Value> = res.json().await?;
if json.is_empty() {
break;
}
for repo in json {
if let Some(url) = repo.get("clone_url").and_then(|u| u.as_str()) {
repos.push(url.to_string());
}
}
page += 1;
} }
// Convert git@ to https:// Ok(repos)
if normalized.starts_with("git@") {
normalized = normalized.replace("git@", "https://").replace(":", "/");
}
// Remove protocol variations
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
View 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).

View 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.

View 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.

View 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.