Compare commits

47 Commits

Author SHA1 Message Date
azalea 7240b05feb [-] Remove unnecessary multiple regex 2026-05-10 10:57:17 +00:00
azalea 1424b9c900 [F] Fix log sync (#6) 2026-05-10 14:14:53 +08:00
azalea c828d1283e [U] info 2026-05-10 05:46:50 +00:00
azalea d0aed91490 [F] Visibility sync 2026-05-10 01:36:06 +00:00
azalea 6f11eeea30 [O] Parallel everything (#5) 2026-05-10 09:28:38 +08:00
azalea 3110653221 [F] Fix gitlab project listing 2026-05-10 01:07:47 +00:00
azalea 2b4695ce9b [F] Fix webhook uninstall 2026-05-09 23:53:43 +00:00
azalea 5827126335 [F] Webhook issues 2026-05-09 23:44:18 +00:00
azalea 888db5d007 [F] Fix webhook uninstall missing url 2026-05-09 22:47:02 +00:00
azalea 1576084f3c [O] Webhook install respect filters 2026-05-09 22:38:50 +00:00
azalea 78e59dad96 [F] Fix gitea pagination 2026-05-09 22:24:13 +00:00
azalea 3ff410734b [O] Test actual PR 2026-05-09 19:37:08 +00:00
azalea 496baa75c9 [-] Remove legacy features 2026-05-09 17:41:20 +00:00
azalea cd5ac0a52e [U] Update deps 2026-05-09 17:07:45 +00:00
azalea 3812704ba2 [O] UX 2026-05-09 08:22:31 +00:00
azalea bf9164f6d3 [O] UX 2026-05-08 16:24:23 +00:00
azalea 74e4a6eff4 [+] End-to-end testing 2026-05-08 15:32:33 +00:00
azalea ac2f2559c1 [+] Track repo deletions 2026-05-08 07:01:32 +00:00
azalea 2caa2e716e [U] Readme 2026-05-08 06:59:17 +00:00
azalea 88883ffc16 [U] Readme 2026-05-08 06:57:55 +00:00
azalea 8c99a3c403 [O] Readme 2026-05-08 06:44:26 +00:00
azalea 0f406d8cb0 [F] Fix build 2026-05-08 06:33:42 +00:00
azalea 4b9de29fdc [+] Docker 2026-05-08 06:29:13 +00:00
azalea 3c287e04d0 [O] Rewrite readme 2026-05-08 05:58:02 +00:00
azalea 73ccb7a559 [M] Rebrand 2026-05-08 05:34:08 +00:00
azalea 8feaea1879 [+] Conflict resolution (#4)
* [+] Conflict resolution

* [F] Fix conflict resolution branches being synched
2026-05-08 13:16:13 +08:00
azalea a7dfd2ccc9 [O] Rework webhook 2026-05-08 04:13:45 +00:00
azalea 09e95f4ad5 [O] Better webhook error handling 2026-05-08 01:03:30 +00:00
azalea 0bc4abf1a7 [O] Better interactive config 2026-05-08 00:52:37 +00:00
azalea f0153ea396 [+] Github Actions builder 2026-05-07 18:48:04 +00:00
azalea 1c413d630a [M] Move tests 2026-05-07 18:27:38 +00:00
azalea 496a57f48d [O] Cleanup codebase 2026-05-07 18:08:33 +00:00
azalea 12b75ae6d3 [O] Clean up codebase 2026-05-07 16:41:18 +00:00
azalea ef6d96e3ac Revert "[O] Use libraries"
This reverts commit ce4a195b4a.
2026-05-07 16:27:28 +00:00
azalea ce4a195b4a [O] Use libraries 2026-05-07 15:03:47 +00:00
azalea c4473d11e3 [+] Webhook mode 2026-05-07 04:55:49 +00:00
azalea 84865134b6 [+] Forgejo (#3)
* [+] Forgejo, tangled

* [-] Tangled
2026-05-07 12:45:01 +08:00
azalea 09657d9adf [F] Fix branch deletion awareness 2026-05-07 02:58:43 +00:00
azalea 44c660e6c2 [F] Fix heuristic 2026-05-07 02:22:34 +00:00
azalea b49660a2eb [O] Better multithreaded logging 2026-05-06 20:19:13 +00:00
azalea 5fd2ce55fb [+] Multithreading 2026-05-06 20:05:59 +00:00
azalea 47b2d73e13 [O] Optimize sync with heuristic 2026-05-06 18:32:16 +00:00
azalea b63ae0b9ca [+] Retry 2026-05-06 17:42:20 +00:00
azalea 723822fbb2 [O] Better CLI wizard (#2) 2026-05-04 06:49:25 +08:00
azalea 236ec5bb8b [O] Better CLI wizard (#1) 2026-05-04 01:54:43 +08:00
azalea 86e8d16759 Initial commit 2026-05-03 17:27:50 +00:00
azalea 898bf237ac [+] Create repo by codex 2026-05-03 17:24:24 +00:00
12 changed files with 308 additions and 146 deletions
+3 -3
View File
@@ -69,8 +69,8 @@ token = { value = "gitea_pat_..." }
[[mirrors]] [[mirrors]]
name = "personal" name = "personal"
sync_visibility = "all" sync_visibility = "all"
repo_whitelist = ["^important-"] repo_whitelist = "^important-"
repo_blacklist = ["-archive$"] repo_blacklist = "-archive$"
create_missing = true create_missing = true
visibility = "private" visibility = "private"
conflict_resolution = "auto_rebase_pull_request" conflict_resolution = "auto_rebase_pull_request"
@@ -179,7 +179,7 @@ Each mirror group is treated as a set of equivalent namespaces. Repositories are
Set `sync_visibility = "all"`, `"private"`, or `"public"` on a mirror group to choose which repository visibility is included in that group. When `refray` creates a missing repository, it mirrors the visibility of the existing repository it is syncing from; `visibility` is only a fallback when no source visibility is available. Set `sync_visibility = "all"`, `"private"`, or `"public"` on a mirror group to choose which repository visibility is included in that group. When `refray` creates a missing repository, it mirrors the visibility of the existing repository it is syncing from; `visibility` is only a fallback when no source visibility is available.
Set `repo_whitelist = ["..."]` and/or `repo_blacklist = ["..."]` on a mirror group to filter repository names with regular expressions. An empty whitelist includes all repository names, and blacklist matches are excluded after whitelist matches. These name filters are independent from `sync_visibility`; both must match for a repository to be synced. Set `repo_whitelist = "..."` and/or `repo_blacklist = "..."` on a mirror group to filter repository names with regular expressions. Omit `repo_whitelist` to include all repository names, and blacklist matches are excluded after whitelist matches. These name filters are independent from `sync_visibility`; both must match for a repository to be synced.
For every repository name found in any endpoint, `refray` will: For every repository name found in any endpoint, `refray` will:
+29 -23
View File
@@ -56,10 +56,10 @@ pub struct MirrorConfig {
pub endpoints: Vec<EndpointConfig>, pub endpoints: Vec<EndpointConfig>,
#[serde(default)] #[serde(default)]
pub sync_visibility: SyncVisibility, pub sync_visibility: SyncVisibility,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_whitelist: Vec<String>, pub repo_whitelist: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_blacklist: Vec<String>, pub repo_blacklist: Option<String>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub create_missing: bool, pub create_missing: bool,
#[serde(default)] #[serde(default)]
@@ -135,8 +135,8 @@ pub enum SyncVisibility {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RepoNameFilter { pub struct RepoNameFilter {
whitelist: Vec<Regex>, whitelist: Option<Regex>,
blacklist: Vec<Regex>, blacklist: Option<Regex>,
} }
impl SyncVisibility { impl SyncVisibility {
@@ -152,35 +152,41 @@ impl SyncVisibility {
impl MirrorConfig { impl MirrorConfig {
pub fn repo_filter(&self) -> Result<RepoNameFilter> { pub fn repo_filter(&self) -> Result<RepoNameFilter> {
Ok(RepoNameFilter { Ok(RepoNameFilter {
whitelist: compile_repo_patterns(&self.name, "repo_whitelist", &self.repo_whitelist)?, whitelist: compile_repo_pattern(&self.name, "repo_whitelist", &self.repo_whitelist)?,
blacklist: compile_repo_patterns(&self.name, "repo_blacklist", &self.repo_blacklist)?, blacklist: compile_repo_pattern(&self.name, "repo_blacklist", &self.repo_blacklist)?,
}) })
} }
} }
impl RepoNameFilter { impl RepoNameFilter {
pub fn matches(&self, repo_name: &str) -> bool { pub fn matches(&self, repo_name: &str) -> bool {
let whitelisted = self.whitelist.is_empty() let whitelisted = self
|| self .whitelist
.whitelist .as_ref()
.iter() .is_none_or(|pattern| pattern.is_match(repo_name));
.any(|pattern| pattern.is_match(repo_name));
let blacklisted = self let blacklisted = self
.blacklist .blacklist
.iter() .as_ref()
.any(|pattern| pattern.is_match(repo_name)); .is_some_and(|pattern| pattern.is_match(repo_name));
whitelisted && !blacklisted whitelisted && !blacklisted
} }
} }
fn compile_repo_patterns(mirror: &str, field: &str, patterns: &[String]) -> Result<Vec<Regex>> { fn compile_repo_pattern(
patterns mirror: &str,
.iter() field: &str,
.map(|pattern| { pattern: &Option<String>,
Regex::new(pattern) ) -> Result<Option<Regex>> {
.with_context(|| format!("mirror '{mirror}' has invalid {field} regex '{pattern}'")) let Some(pattern) = pattern
}) .as_deref()
.collect() .map(str::trim)
.filter(|pattern| !pattern.is_empty())
else {
return Ok(None);
};
Regex::new(pattern)
.with_context(|| format!("mirror '{mirror}' has invalid {field} regex '{pattern}'"))
.map(Some)
} }
fn default_true() -> bool { fn default_true() -> bool {
+27 -36
View File
@@ -38,8 +38,8 @@ struct ParsedProfileUrl {
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
struct RepoFilterInput { struct RepoFilterInput {
whitelist: Vec<String>, whitelist: Option<String>,
blacklist: Vec<String>, blacklist: Option<String>,
} }
pub fn run_config_wizard(path: &Path) -> Result<ConfigWizardOutcome> { pub fn run_config_wizard(path: &Path) -> Result<ConfigWizardOutcome> {
@@ -780,7 +780,7 @@ fn prompt_repo_filters_styled(
existing: Option<&RepoFilterInput>, existing: Option<&RepoFilterInput>,
) -> Result<RepoFilterInput> { ) -> Result<RepoFilterInput> {
let existing = existing.cloned().unwrap_or_default(); let existing = existing.cloned().unwrap_or_default();
let has_existing = !existing.whitelist.is_empty() || !existing.blacklist.is_empty(); let has_existing = existing.whitelist.is_some() || existing.blacklist.is_some();
if !Confirm::with_theme(theme) if !Confirm::with_theme(theme)
.with_prompt("Configure repository name whitelist/blacklist?") .with_prompt("Configure repository name whitelist/blacklist?")
.default(has_existing) .default(has_existing)
@@ -790,51 +790,44 @@ fn prompt_repo_filters_styled(
} }
Ok(RepoFilterInput { Ok(RepoFilterInput {
whitelist: prompt_repo_pattern_list_styled( whitelist: prompt_repo_pattern_styled(
theme, theme,
"Whitelist regexes (comma-separated, empty means all repo names)", "Whitelist regex (empty means all repo names)",
&existing.whitelist, &existing.whitelist,
)?, )?,
blacklist: prompt_repo_pattern_list_styled( blacklist: prompt_repo_pattern_styled(theme, "Blacklist regex", &existing.blacklist)?,
theme,
"Blacklist regexes (comma-separated)",
&existing.blacklist,
)?,
}) })
} }
fn prompt_repo_pattern_list_styled( fn prompt_repo_pattern_styled(
theme: &ColorfulTheme, theme: &ColorfulTheme,
prompt: &str, prompt: &str,
existing: &[String], existing: &Option<String>,
) -> Result<Vec<String>> { ) -> Result<Option<String>> {
let input = Input::<String>::with_theme(theme) let input = Input::<String>::with_theme(theme)
.with_prompt(prompt) .with_prompt(prompt)
.allow_empty(true) .allow_empty(true)
.validate_with(|value: &String| validate_repo_pattern_list(value)); .validate_with(|value: &String| validate_repo_pattern(value));
let input = if existing.is_empty() { let input = if let Some(existing) = existing {
input input.default(existing.clone())
} else { } else {
input.default(existing.join(", ")) input
}; };
let value = input.interact_text()?; let value = input.interact_text()?;
Ok(parse_repo_pattern_list(&value)) Ok(parse_repo_pattern(&value))
} }
fn validate_repo_pattern_list(value: &str) -> std::result::Result<(), String> { fn validate_repo_pattern(value: &str) -> std::result::Result<(), String> {
for pattern in parse_repo_pattern_list(value) { let Some(pattern) = parse_repo_pattern(value) else {
Regex::new(&pattern).map_err(|error| format!("invalid regex '{pattern}': {error}"))?; return Ok(());
} };
Regex::new(&pattern).map_err(|error| format!("invalid regex '{pattern}': {error}"))?;
Ok(()) Ok(())
} }
fn parse_repo_pattern_list(value: &str) -> Vec<String> { fn parse_repo_pattern(value: &str) -> Option<String> {
value let value = value.trim();
.split(',') (!value.is_empty()).then(|| value.to_string())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect()
} }
fn sync_visibility_index(sync_visibility: &SyncVisibility) -> usize { fn sync_visibility_index(sync_visibility: &SyncVisibility) -> usize {
@@ -937,13 +930,11 @@ fn sync_visibility_label(sync_visibility: &SyncVisibility) -> &'static str {
} }
fn repo_filter_label(mirror: &MirrorConfig) -> String { fn repo_filter_label(mirror: &MirrorConfig) -> String {
match (mirror.repo_whitelist.len(), mirror.repo_blacklist.len()) { match (&mirror.repo_whitelist, &mirror.repo_blacklist) {
(0, 0) => "repos: all names".to_string(), (None, None) => "repos: all names".to_string(),
(whitelist, 0) => format!("repos: whitelist {whitelist}"), (Some(_), None) => "repos: whitelist".to_string(),
(0, blacklist) => format!("repos: blacklist {blacklist}"), (None, Some(_)) => "repos: blacklist".to_string(),
(whitelist, blacklist) => { (Some(_), Some(_)) => "repos: whitelist + blacklist".to_string(),
format!("repos: whitelist {whitelist}, blacklist {blacklist}")
}
} }
} }
+165 -19
View File
@@ -1,14 +1,27 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::fmt; use std::fmt;
use std::io::{self, Write}; use std::io::{self, IsTerminal, Write};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
static OUTPUT: OnceLock<Mutex<()>> = OnceLock::new(); use console::style;
static OUTPUT: OnceLock<Mutex<OutputState>> = OnceLock::new();
thread_local! { thread_local! {
static REPO_LOG: RefCell<Option<ActiveRepoLog>> = const { RefCell::new(None) }; static REPO_LOG: RefCell<Option<ActiveRepoLog>> = const { RefCell::new(None) };
} }
#[derive(Default)]
struct OutputState {
status: Option<StatusState>,
}
struct StatusState {
slots: Vec<Option<String>>,
visible: bool,
interactive: bool,
}
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct RepoLogContext { pub(crate) struct RepoLogContext {
inner: Arc<RepoLog>, inner: Arc<RepoLog>,
@@ -20,9 +33,20 @@ struct ActiveRepoLog {
} }
struct RepoLog { struct RepoLog {
repo_name: String,
slot: usize,
width: usize,
lines: Mutex<Vec<String>>, lines: Mutex<Vec<String>>,
} }
pub struct StatusGuard;
impl Drop for StatusGuard {
fn drop(&mut self) {
finish_status_area();
}
}
pub struct RepoLogGuard; pub struct RepoLogGuard;
impl Drop for RepoLogGuard { impl Drop for RepoLogGuard {
@@ -31,9 +55,29 @@ impl Drop for RepoLogGuard {
} }
} }
pub fn start_repo_log() -> RepoLogGuard { pub fn start_status_area(slots: usize) -> StatusGuard {
with_output(|output| {
if let Some(status) = output.status.as_mut() {
clear_status(status);
}
output.status = Some(StatusState {
slots: vec![None; slots],
visible: false,
interactive: io::stdout().is_terminal() && slots > 0,
});
if let Some(status) = output.status.as_mut() {
draw_status(status);
}
});
StatusGuard
}
pub fn start_repo_log(repo_name: String, slot: usize, width: usize) -> RepoLogGuard {
let context = RepoLogContext { let context = RepoLogContext {
inner: Arc::new(RepoLog { inner: Arc::new(RepoLog {
repo_name,
slot,
width,
lines: Mutex::new(Vec::new()), lines: Mutex::new(Vec::new()),
}), }),
}; };
@@ -100,13 +144,31 @@ pub fn finish_repo_log() {
std::mem::take(&mut *lines) std::mem::take(&mut *lines)
}; };
with_output(|| { with_output(|output| {
if let Some(status) = output.status.as_mut() {
clear_status(status);
if repo_log.slot < status.slots.len() {
status.slots[repo_log.slot] = None;
}
}
for line in lines { for line in lines {
println!("{line}"); println!("{line}");
} }
if let Some(status) = output.status.as_mut() {
draw_status(status);
}
}); });
} }
pub fn repo_prefix(repo_name: &str, width: usize) -> String {
let mut prefix = repo_name.chars().take(width).collect::<String>();
if repo_name.chars().count() > width && width > 0 {
prefix.pop();
prefix.push('~');
}
format!("{prefix:<width$}")
}
pub fn line(args: fmt::Arguments<'_>) { pub fn line(args: fmt::Arguments<'_>) {
let text = args.to_string(); let text = args.to_string();
let context = current_repo_log_context(); let context = current_repo_log_context();
@@ -115,35 +177,119 @@ pub fn line(args: fmt::Arguments<'_>) {
return; return;
} }
with_output(|| { with_output(|output| {
if let Some(status) = output.status.as_mut() {
clear_status(status);
}
println!("{text}"); println!("{text}");
if let Some(status) = output.status.as_mut() {
draw_status(status);
}
}); });
} }
fn capture_repo_line(context: &RepoLogContext, text: &str) { fn capture_repo_line(context: &RepoLogContext, text: &str) {
let mut lines = context let mut status_updates = Vec::new();
.inner {
.lines let mut lines = context
.lock() .inner
.unwrap_or_else(|poisoned| poisoned.into_inner()); .lines
if text.is_empty() { .lock()
lines.push(String::new()); .unwrap_or_else(|poisoned| poisoned.into_inner());
return; if text.is_empty() {
lines.push(String::new());
return;
}
for line in text.lines() {
lines.push(line.to_string());
if !line.trim().is_empty() {
status_updates.push(line.trim().to_string());
}
}
} }
for line in text.lines() { for line in status_updates {
lines.push(line.to_string()); update_status(context, &line);
} }
} }
fn with_output(action: impl FnOnce()) { fn update_status(context: &RepoLogContext, line: &str) {
let output = OUTPUT.get_or_init(|| Mutex::new(())); let repo_log = &context.inner;
let _output = output let repo = repo_prefix(&repo_log.repo_name, repo_log.width);
let line = truncate_status(line, 96);
with_output(|output| {
let Some(status) = output.status.as_mut() else {
return;
};
if repo_log.slot >= status.slots.len() {
return;
}
clear_status(status);
status.slots[repo_log.slot] = Some(format!(
"{} {} {}",
style(format!("worker {}", repo_log.slot + 1)).dim(),
style(repo).cyan().bold(),
line
));
draw_status(status);
});
}
fn truncate_status(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
let mut output = value.chars().take(max_chars).collect::<String>();
output.pop();
output.push('~');
output
}
fn finish_status_area() {
with_output(|output| {
if let Some(status) = output.status.as_mut() {
clear_status(status);
}
output.status = None;
});
}
fn with_output(action: impl FnOnce(&mut OutputState)) {
let output = OUTPUT.get_or_init(|| Mutex::new(OutputState::default()));
let mut output = output
.lock() .lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()); .unwrap_or_else(|poisoned| poisoned.into_inner());
action(); action(&mut output);
let _ = io::stdout().flush(); let _ = io::stdout().flush();
} }
fn clear_status(status: &mut StatusState) {
if !status.interactive || !status.visible {
return;
}
let lines = status.slots.len();
print!("\x1b[{lines}A\r");
for _ in 0..lines {
println!("\x1b[2K");
}
print!("\x1b[{lines}A\r");
status.visible = false;
}
fn draw_status(status: &mut StatusState) {
if !status.interactive {
return;
}
for slot in &status.slots {
match slot {
Some(line) => println!("{line}"),
None => println!("{}", style("idle").dim()),
}
}
status.visible = true;
}
#[macro_export] #[macro_export]
macro_rules! logln { macro_rules! logln {
() => { () => {
+16 -3
View File
@@ -237,6 +237,7 @@ fn sync_group(
); );
} }
let repo_log_width = repo_log_width(&repo_names);
let repo_jobs = repo_names let repo_jobs = repo_names
.into_iter() .into_iter()
.map(|repo_name| { .map(|repo_name| {
@@ -259,10 +260,11 @@ fn sync_group(
let base_ref_state = context.ref_state.clone(); let base_ref_state = context.ref_state.clone();
let queue = Arc::new(Mutex::new(repo_jobs)); let queue = Arc::new(Mutex::new(repo_jobs));
let (sender, receiver) = mpsc::channel(); let (sender, receiver) = mpsc::channel();
let use_repo_logs = worker_count > 1; let use_status_area = worker_count > 1;
let jobs = context.options.jobs; let jobs = context.options.jobs;
let _status_guard = use_status_area.then(|| logging::start_status_area(worker_count));
let failures = thread::scope(|scope| { let failures = thread::scope(|scope| {
for _ in 0..worker_count { for worker_id in 0..worker_count {
let queue = Arc::clone(&queue); let queue = Arc::clone(&queue);
let sender = sender.clone(); let sender = sender.clone();
let redactor = context.redactor.clone(); let redactor = context.redactor.clone();
@@ -273,7 +275,9 @@ fn sync_group(
scope.spawn(move || { scope.spawn(move || {
while let Some(mut job) = pop_repo_job(&queue) { while let Some(mut job) = pop_repo_job(&queue) {
let _repo_log_guard = use_repo_logs.then(logging::start_repo_log); let _repo_log_guard = use_status_area.then(|| {
logging::start_repo_log(job.repo_name.clone(), worker_id, repo_log_width)
});
let repo_context = RepoSyncContext { let repo_context = RepoSyncContext {
config, config,
mirror, mirror,
@@ -379,6 +383,15 @@ fn pop_repo_job(queue: &Arc<Mutex<VecDeque<RepoSyncJob>>>) -> Option<RepoSyncJob
.pop_front() .pop_front()
} }
fn repo_log_width(repo_names: &BTreeSet<String>) -> usize {
repo_names
.iter()
.map(|name| name.chars().count())
.max()
.unwrap_or(4)
.clamp(4, 32)
}
struct RepoSyncJob { struct RepoSyncJob {
repo_name: String, repo_name: String,
existing: Vec<EndpointRepo>, existing: Vec<EndpointRepo>,
+2 -2
View File
@@ -339,7 +339,7 @@ secret = {{ value = "{WEBHOOK_SECRET}" }}
[[mirrors]] [[mirrors]]
name = "all" name = "all"
sync_visibility = "all" sync_visibility = "all"
repo_whitelist = ['{}'] repo_whitelist = '{}'
create_missing = {} create_missing = {}
visibility = "public" visibility = "public"
conflict_resolution = "{}" conflict_resolution = "{}"
@@ -788,7 +788,7 @@ namespace = "{}"
let contents = fs::read_to_string(&self.config_path) let contents = fs::read_to_string(&self.config_path)
.with_context(|| format!("failed to read {}", self.config_path.display()))?; .with_context(|| format!("failed to read {}", self.config_path.display()))?;
let escaped_pattern = pattern.replace('\'', "''"); let escaped_pattern = pattern.replace('\'', "''");
let replacement = format!("repo_whitelist = ['{escaped_pattern}']"); let replacement = format!("repo_whitelist = '{escaped_pattern}'");
let mut replaced = false; let mut replaced = false;
let mut updated = contents let mut updated = contents
.lines() .lines()
+15 -15
View File
@@ -22,8 +22,8 @@ fn parses_value_tokens() {
[[mirrors]] [[mirrors]]
name = "personal" name = "personal"
sync_visibility = "public" sync_visibility = "public"
repo_whitelist = ["^important-", "-mirror$"] repo_whitelist = "^important-|-mirror$"
repo_blacklist = ["-archive$"] repo_blacklist = "-archive$"
create_missing = true create_missing = true
visibility = "private" visibility = "private"
conflict_resolution = "auto_rebase_pull_request" conflict_resolution = "auto_rebase_pull_request"
@@ -51,11 +51,11 @@ fn parses_value_tokens() {
assert_eq!(config.mirrors[0].sync_visibility, SyncVisibility::Public); assert_eq!(config.mirrors[0].sync_visibility, SyncVisibility::Public);
assert_eq!( assert_eq!(
config.mirrors[0].repo_whitelist, config.mirrors[0].repo_whitelist,
vec!["^important-".to_string(), "-mirror$".to_string()] Some("^important-|-mirror$".to_string())
); );
assert_eq!( assert_eq!(
config.mirrors[0].repo_blacklist, config.mirrors[0].repo_blacklist,
vec!["-archive$".to_string()] Some("-archive$".to_string())
); );
let webhook = config.webhook.unwrap(); let webhook = config.webhook.unwrap();
assert!(webhook.install); assert!(webhook.install);
@@ -105,8 +105,8 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
namespace: "alice".to_string(), namespace: "alice".to_string(),
}], }],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
@@ -134,8 +134,8 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
@@ -199,8 +199,8 @@ fn sync_visibility_matches_repo_privacy() {
#[test] #[test]
fn repo_name_filter_applies_whitelist_then_blacklist() { fn repo_name_filter_applies_whitelist_then_blacklist() {
let mut mirror = mirror_config(); let mut mirror = mirror_config();
mirror.repo_whitelist = vec!["^important-".to_string(), "-mirror$".to_string()]; mirror.repo_whitelist = Some("^important-|-mirror$".to_string());
mirror.repo_blacklist = vec!["-archive$".to_string()]; mirror.repo_blacklist = Some("-archive$".to_string());
let filter = mirror.repo_filter().unwrap(); let filter = mirror.repo_filter().unwrap();
assert!(filter.matches("important-api")); assert!(filter.matches("important-api"));
@@ -217,7 +217,7 @@ fn validation_rejects_invalid_repo_filter_regex() {
mirrors: vec![mirror_config()], mirrors: vec![mirror_config()],
webhook: None, webhook: None,
}; };
config.mirrors[0].repo_whitelist = vec!["(".to_string()]; config.mirrors[0].repo_whitelist = Some("(".to_string());
let err = validate_config(&config).unwrap_err().to_string(); let err = validate_config(&config).unwrap_err().to_string();
@@ -238,8 +238,8 @@ fn validation_rejects_duplicate_mirror_endpoints() {
name: "broken".to_string(), name: "broken".to_string(),
endpoints: vec![duplicate.clone(), duplicate], endpoints: vec![duplicate.clone(), duplicate],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
@@ -284,8 +284,8 @@ fn mirror_config() -> MirrorConfig {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
+14 -14
View File
@@ -211,8 +211,8 @@ fn wizard_starts_existing_config_at_sync_group_menu() {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
@@ -240,8 +240,8 @@ fn wizard_can_ask_to_run_full_sync_after_config() {
name: "sync-1".to_string(), name: "sync-1".to_string(),
endpoints: Vec::new(), endpoints: Vec::new(),
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
@@ -316,8 +316,8 @@ fn wizard_edits_existing_sync_group_from_menu() {
}, },
], ],
sync_visibility: SyncVisibility::Private, sync_visibility: SyncVisibility::Private,
repo_whitelist: vec!["^important-".to_string()], repo_whitelist: Some("^important-".to_string()),
repo_blacklist: vec!["-archive$".to_string()], repo_blacklist: Some("-archive$".to_string()),
create_missing: false, create_missing: false,
visibility: Visibility::Public, visibility: Visibility::Public,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
@@ -357,8 +357,8 @@ fn wizard_edits_existing_sync_group_from_menu() {
assert_eq!(mirror.endpoints[1].namespace, "bob"); assert_eq!(mirror.endpoints[1].namespace, "bob");
assert!(!mirror.create_missing); assert!(!mirror.create_missing);
assert_eq!(mirror.sync_visibility, SyncVisibility::Public); assert_eq!(mirror.sync_visibility, SyncVisibility::Public);
assert_eq!(mirror.repo_whitelist, vec!["^public-".to_string()]); assert_eq!(mirror.repo_whitelist, Some("^public-".to_string()));
assert_eq!(mirror.repo_blacklist, vec!["-skip$".to_string()]); assert_eq!(mirror.repo_blacklist, Some("-skip$".to_string()));
assert_eq!(mirror.visibility, Visibility::Public); assert_eq!(mirror.visibility, Visibility::Public);
let output = String::from_utf8(output).unwrap(); let output = String::from_utf8(output).unwrap();
assert!(output.contains("Edit sync group")); assert!(output.contains("Edit sync group"));
@@ -403,8 +403,8 @@ fn wizard_prefills_existing_sync_group_when_editing() {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
@@ -467,8 +467,8 @@ fn wizard_deletes_existing_sync_group_from_menu() {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
@@ -526,8 +526,8 @@ fn wizard_can_go_back_from_delete_menu() {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
+12 -18
View File
@@ -531,7 +531,7 @@ where
W: Write, W: Write,
{ {
let existing = existing.cloned().unwrap_or_default(); let existing = existing.cloned().unwrap_or_default();
let has_existing = !existing.whitelist.is_empty() || !existing.blacklist.is_empty(); let has_existing = existing.whitelist.is_some() || existing.blacklist.is_some();
if !prompt_bool( if !prompt_bool(
reader, reader,
writer, writer,
@@ -542,40 +542,34 @@ where
} }
Ok(RepoFilterInput { Ok(RepoFilterInput {
whitelist: prompt_repo_pattern_list( whitelist: prompt_repo_pattern(
reader, reader,
writer, writer,
"Whitelist regexes (comma-separated, empty means all repo names)", "Whitelist regex (empty means all repo names)",
&existing.whitelist, &existing.whitelist,
)?, )?,
blacklist: prompt_repo_pattern_list( blacklist: prompt_repo_pattern(reader, writer, "Blacklist regex", &existing.blacklist)?,
reader,
writer,
"Blacklist regexes (comma-separated)",
&existing.blacklist,
)?,
}) })
} }
fn prompt_repo_pattern_list<R, W>( fn prompt_repo_pattern<R, W>(
reader: &mut R, reader: &mut R,
writer: &mut W, writer: &mut W,
label: &str, label: &str,
existing: &[String], existing: &Option<String>,
) -> Result<Vec<String>> ) -> Result<Option<String>>
where where
R: BufRead, R: BufRead,
W: Write, W: Write,
{ {
let value = if existing.is_empty() { let value = match existing {
prompt_optional(reader, writer, label)? Some(existing) => prompt_with_default(reader, writer, label, existing)?,
} else { None => prompt_optional(reader, writer, label)?,
prompt_with_default(reader, writer, label, &existing.join(", "))?
}; };
if let Err(error) = validate_repo_pattern_list(&value) { if let Err(error) = validate_repo_pattern(&value) {
bail!(error); bail!(error);
} }
Ok(parse_repo_pattern_list(&value)) Ok(parse_repo_pattern(&value))
} }
fn sync_visibility_value(sync_visibility: &SyncVisibility) -> &'static str { fn sync_visibility_value(sync_visibility: &SyncVisibility) -> &'static str {
+13 -1
View File
@@ -1,8 +1,20 @@
use super::*; use super::*;
#[test]
fn repo_prefix_pads_and_truncates_to_fixed_width() {
assert_eq!(repo_prefix("api", 6), "api ");
assert_eq!(repo_prefix("very-long-repo", 8), "very-lo~");
}
#[test]
fn status_text_truncates_to_fixed_width() {
assert_eq!(truncate_status("short", 8), "short");
assert_eq!(truncate_status("very-long-status", 8), "very-lo~");
}
#[test] #[test]
fn repo_log_context_is_inherited_by_parallel_workers() { fn repo_log_context_is_inherited_by_parallel_workers() {
let _guard = start_repo_log(); let _guard = start_repo_log("repo-a".to_string(), 0, 8);
crate::logln!("outer line"); crate::logln!("outer line");
crate::parallel::map(vec!["worker line"], 1, |line| { crate::parallel::map(vec!["worker line"], 1, |line| {
+3 -3
View File
@@ -344,7 +344,7 @@ fn all_visibility_keeps_state_only_repos_for_deletion_detection() {
#[test] #[test]
fn repo_name_filters_do_not_treat_state_only_repos_as_deleted() { fn repo_name_filters_do_not_treat_state_only_repos_as_deleted() {
let mut mirror = test_mirror(); let mut mirror = test_mirror();
mirror.repo_whitelist = vec!["^public-".to_string()]; mirror.repo_whitelist = Some("^public-".to_string());
let repo_filter = mirror.repo_filter().unwrap(); let repo_filter = mirror.repo_filter().unwrap();
let mut ref_state = RefState::default(); let mut ref_state = RefState::default();
ref_state.set_repo( ref_state.set_repo(
@@ -472,8 +472,8 @@ fn test_mirror() -> MirrorConfig {
name: "sync-1".to_string(), name: "sync-1".to_string(),
endpoints: vec![endpoint("github"), endpoint("gitea")], endpoints: vec![endpoint("github"), endpoint("gitea")],
sync_visibility: crate::config::SyncVisibility::All, sync_visibility: crate::config::SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: crate::config::Visibility::Private, visibility: crate::config::Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
+9 -9
View File
@@ -112,8 +112,8 @@ fn matches_jobs_by_provider_and_namespace() {
endpoint("gitea", NamespaceKind::User, "azalea"), endpoint("gitea", NamespaceKind::User, "azalea"),
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
@@ -139,8 +139,8 @@ fn matching_jobs_respects_repo_name_filters() {
name: "sync-1".to_string(), name: "sync-1".to_string(),
endpoints: vec![endpoint("github", NamespaceKind::User, "alice")], endpoints: vec![endpoint("github", NamespaceKind::User, "alice")],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: vec!["^important-".to_string()], repo_whitelist: Some("^important-".to_string()),
repo_blacklist: vec!["-archive$".to_string()], repo_blacklist: Some("-archive$".to_string()),
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
@@ -159,7 +159,7 @@ fn matching_jobs_respects_repo_name_filters() {
assert!(matching_jobs(&config, &webhook_event("important-archive")).is_empty()); assert!(matching_jobs(&config, &webhook_event("important-archive")).is_empty());
assert!(matching_jobs(&config, &webhook_event("random")).is_empty()); assert!(matching_jobs(&config, &webhook_event("random")).is_empty());
mirror.repo_whitelist.clear(); mirror.repo_whitelist = None;
let config = Config { let config = Config {
jobs: crate::config::DEFAULT_JOBS, jobs: crate::config::DEFAULT_JOBS,
sites: vec![site("github", ProviderKind::Github)], sites: vec![site("github", ProviderKind::Github)],
@@ -357,8 +357,8 @@ fn uninstall_webhooks_skips_blocked_provider_access() {
endpoint("github-peer", NamespaceKind::User, "bob"), endpoint("github-peer", NamespaceKind::User, "bob"),
], ],
sync_visibility: SyncVisibility::Public, sync_visibility: SyncVisibility::Public,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
@@ -710,8 +710,8 @@ fn filtered_mirror() -> MirrorConfig {
endpoint("github-peer", NamespaceKind::User, "bob"), endpoint("github-peer", NamespaceKind::User, "bob"),
], ],
sync_visibility: SyncVisibility::Public, sync_visibility: SyncVisibility::Public,
repo_whitelist: vec!["^important-".to_string()], repo_whitelist: Some("^important-".to_string()),
repo_blacklist: vec!["-archive$".to_string()], repo_blacklist: Some("-archive$".to_string()),
create_missing: true, create_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,