Compare commits

47 Commits

Author SHA1 Message Date
azalea 81edd9b8bf [F] Fix logging 2026-05-10 10:45:33 +00:00
azalea 09cede6658 [F] Fix log sync 2026-05-10 06:14:32 +00:00
azalea 009ba9b247 [U] info 2026-05-10 05:46:50 +00:00
azalea fbf5534fe3 [F] Visibility sync 2026-05-10 01:36:06 +00:00
azalea b41f530d1e [O] Parallel everything (#5) 2026-05-10 09:28:38 +08:00
azalea 915a63a955 [F] Fix gitlab project listing 2026-05-10 01:07:47 +00:00
azalea b0469d80a7 [F] Fix webhook uninstall 2026-05-09 23:53:43 +00:00
azalea 260f42b973 [F] Webhook issues 2026-05-09 23:44:18 +00:00
azalea 44b1865b15 [F] Fix webhook uninstall missing url 2026-05-09 22:47:02 +00:00
azalea de88150445 [O] Webhook install respect filters 2026-05-09 22:38:50 +00:00
azalea f94a0f11b5 [F] Fix gitea pagination 2026-05-09 22:24:13 +00:00
azalea 0ee43ea58f [O] Test actual PR 2026-05-09 19:37:08 +00:00
azalea 10c55062eb [-] Remove legacy features 2026-05-09 17:41:20 +00:00
azalea f3c0b90a0d [U] Update deps 2026-05-09 17:07:45 +00:00
azalea 513bda3696 [O] UX 2026-05-09 08:22:31 +00:00
azalea 018f1f12d5 [O] UX 2026-05-08 16:24:23 +00:00
azalea 3d73f20c1f [+] End-to-end testing 2026-05-08 15:32:33 +00:00
azalea e43e555b37 [+] Track repo deletions 2026-05-08 07:01:32 +00:00
azalea bc6509ad59 [U] Readme 2026-05-08 06:59:17 +00:00
azalea f28f96f27c [U] Readme 2026-05-08 06:57:55 +00:00
azalea 527e69dc4a [O] Readme 2026-05-08 06:44:26 +00:00
azalea 566c3c1b59 [F] Fix build 2026-05-08 06:33:42 +00:00
azalea b7f3404f99 [+] Docker 2026-05-08 06:29:13 +00:00
azalea d19b061f7c [O] Rewrite readme 2026-05-08 05:58:02 +00:00
azalea 9a9cbba767 [M] Rebrand 2026-05-08 05:34:08 +00:00
azalea ebeb045c51 [+] Conflict resolution (#4)
* [+] Conflict resolution

* [F] Fix conflict resolution branches being synched
2026-05-08 13:16:13 +08:00
azalea 67dd55a1cf [O] Rework webhook 2026-05-08 04:13:45 +00:00
azalea 0566e97c6a [O] Better webhook error handling 2026-05-08 01:03:30 +00:00
azalea 60b6caf1a6 [O] Better interactive config 2026-05-08 00:52:37 +00:00
azalea 9c651728e4 [+] Github Actions builder 2026-05-07 18:48:04 +00:00
azalea 7fc3ab32ad [M] Move tests 2026-05-07 18:27:38 +00:00
azalea 17e3961267 [O] Cleanup codebase 2026-05-07 18:08:33 +00:00
azalea 19658c4ba9 [O] Clean up codebase 2026-05-07 16:41:18 +00:00
azalea 4545a5e515 Revert "[O] Use libraries"
This reverts commit b225c4bbd8.
2026-05-07 16:27:28 +00:00
azalea b225c4bbd8 [O] Use libraries 2026-05-07 15:03:47 +00:00
azalea c013ce1858 [+] Webhook mode 2026-05-07 04:55:49 +00:00
azalea 7b65d919d6 [+] Forgejo (#3)
* [+] Forgejo, tangled

* [-] Tangled
2026-05-07 12:45:01 +08:00
azalea 39ba96051c [F] Fix branch deletion awareness 2026-05-07 02:58:43 +00:00
azalea b70eaee2aa [F] Fix heuristic 2026-05-07 02:22:34 +00:00
azalea 92bcee49ea [O] Better multithreaded logging 2026-05-06 20:19:13 +00:00
azalea bb991d94f0 [+] Multithreading 2026-05-06 20:05:59 +00:00
azalea 61450e3a97 [O] Optimize sync with heuristic 2026-05-06 18:32:16 +00:00
azalea 18cf024b6e [+] Retry 2026-05-06 17:42:20 +00:00
azalea 3c0b3fc1e8 [O] Better CLI wizard (#2) 2026-05-04 06:49:25 +08:00
azalea 0cdabb09e6 [O] Better CLI wizard (#1) 2026-05-04 01:54:43 +08:00
azalea fd7e0db96d Initial commit 2026-05-03 17:27:50 +00:00
azalea 947e71d1fd [+] Create repo by codex 2026-05-03 17:24:24 +00:00
4 changed files with 126 additions and 198 deletions
+99 -174
View File
@@ -1,40 +1,26 @@
use std::cell::RefCell;
use std::fmt;
use std::io::{self, IsTerminal, Write};
use std::sync::{Mutex, OnceLock};
use std::io::{self, Write};
use std::sync::{Arc, Mutex, OnceLock};
use console::style;
static OUTPUT: OnceLock<Mutex<OutputState>> = OnceLock::new();
static OUTPUT: OnceLock<Mutex<()>> = OnceLock::new();
thread_local! {
static REPO_LOG: RefCell<Option<RepoLog>> = const { RefCell::new(None) };
static REPO_LOG: RefCell<Option<ActiveRepoLog>> = const { RefCell::new(None) };
}
#[derive(Default)]
struct OutputState {
status: Option<StatusState>,
#[derive(Clone)]
pub(crate) struct RepoLogContext {
inner: Arc<RepoLog>,
}
struct StatusState {
slots: Vec<Option<String>>,
visible: bool,
interactive: bool,
struct ActiveRepoLog {
context: RepoLogContext,
owner: bool,
}
struct RepoLog {
repo_name: String,
slot: usize,
width: usize,
lines: Vec<String>,
}
pub struct StatusGuard;
impl Drop for StatusGuard {
fn drop(&mut self) {
finish_status_area();
}
lines: Mutex<Vec<String>>,
}
pub struct RepoLogGuard;
@@ -45,180 +31,119 @@ impl Drop for 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 {
pub fn start_repo_log() -> RepoLogGuard {
let context = RepoLogContext {
inner: Arc::new(RepoLog {
lines: Mutex::new(Vec::new()),
}),
};
REPO_LOG.with(|repo_log| {
*repo_log.borrow_mut() = Some(RepoLog {
repo_name,
slot,
width,
lines: Vec::new(),
*repo_log.borrow_mut() = Some(ActiveRepoLog {
context,
owner: true,
});
});
RepoLogGuard
}
pub fn finish_repo_log() {
let repo_log = REPO_LOG.with(|repo_log| repo_log.borrow_mut().take());
let Some(repo_log) = repo_log else {
return;
};
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 repo_log.lines {
println!("{line}");
}
if let Some(status) = output.status.as_mut() {
draw_status(status);
}
});
pub(crate) fn current_repo_log_context() -> Option<RepoLogContext> {
REPO_LOG.with(|repo_log| {
repo_log
.borrow()
.as_ref()
.map(|repo_log| repo_log.context.clone())
})
}
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('~');
pub(crate) fn inherit_repo_log(context: Option<RepoLogContext>) -> InheritedRepoLogGuard {
let previous = REPO_LOG.with(|repo_log| {
let mut repo_log = repo_log.borrow_mut();
let previous = repo_log.take();
if let Some(context) = context {
*repo_log = Some(ActiveRepoLog {
context,
owner: false,
});
}
previous
});
InheritedRepoLogGuard { previous }
}
pub(crate) struct InheritedRepoLogGuard {
previous: Option<ActiveRepoLog>,
}
impl Drop for InheritedRepoLogGuard {
fn drop(&mut self) {
REPO_LOG.with(|repo_log| {
*repo_log.borrow_mut() = self.previous.take();
});
}
format!("{prefix:<width$}")
}
pub fn finish_repo_log() {
let active = REPO_LOG.with(|repo_log| repo_log.borrow_mut().take());
let Some(active) = active else {
return;
};
if !active.owner {
return;
}
let repo_log = active.context.inner;
let lines = {
let mut lines = repo_log
.lines
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::mem::take(&mut *lines)
};
with_output(|| {
for line in lines {
println!("{line}");
}
});
}
pub fn line(args: fmt::Arguments<'_>) {
let text = args.to_string();
let captured = REPO_LOG.with(|repo_log| {
let mut repo_log = repo_log.borrow_mut();
let Some(repo_log) = repo_log.as_mut() else {
return false;
};
if text.is_empty() {
repo_log.lines.push(String::new());
return true;
}
for line in text.lines() {
repo_log.lines.push(line.to_string());
if !line.trim().is_empty() {
update_status(repo_log, line.trim());
}
}
true
});
if captured {
let context = current_repo_log_context();
if let Some(context) = context {
capture_repo_line(&context, &text);
return;
}
with_output(|output| {
if let Some(status) = output.status.as_mut() {
clear_status(status);
}
with_output(|| {
println!("{text}");
if let Some(status) = output.status.as_mut() {
draw_status(status);
}
});
}
fn update_status(repo_log: &RepoLog, line: &str) {
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
fn capture_repo_line(context: &RepoLogContext, text: &str) {
let mut lines = context
.inner
.lines
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
action(&mut output);
if text.is_empty() {
lines.push(String::new());
return;
}
for line in text.lines() {
lines.push(line.to_string());
}
}
fn with_output(action: impl FnOnce()) {
let output = OUTPUT.get_or_init(|| Mutex::new(()));
let _output = output
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
action();
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_rules! logln {
() => {
+3
View File
@@ -20,13 +20,16 @@ where
let worker_count = jobs.min(items.len());
let queue = Arc::new(Mutex::new(VecDeque::from(items)));
let (sender, receiver) = mpsc::channel();
let repo_log_context = crate::logging::current_repo_log_context();
thread::scope(|scope| {
for _ in 0..worker_count {
let queue = Arc::clone(&queue);
let sender = sender.clone();
let f = &f;
let repo_log_context = repo_log_context.clone();
scope.spawn(move || {
let _repo_log_guard = crate::logging::inherit_repo_log(repo_log_context);
while let Some(item) = pop_item(&queue) {
if sender.send(f(item)).is_err() {
break;
+3 -16
View File
@@ -237,7 +237,6 @@ fn sync_group(
);
}
let repo_log_width = repo_log_width(&repo_names);
let repo_jobs = repo_names
.into_iter()
.map(|repo_name| {
@@ -260,11 +259,10 @@ fn sync_group(
let base_ref_state = context.ref_state.clone();
let queue = Arc::new(Mutex::new(repo_jobs));
let (sender, receiver) = mpsc::channel();
let use_status_area = worker_count > 1;
let use_repo_logs = worker_count > 1;
let jobs = context.options.jobs;
let _status_guard = use_status_area.then(|| logging::start_status_area(worker_count));
let failures = thread::scope(|scope| {
for worker_id in 0..worker_count {
for _ in 0..worker_count {
let queue = Arc::clone(&queue);
let sender = sender.clone();
let redactor = context.redactor.clone();
@@ -275,9 +273,7 @@ fn sync_group(
scope.spawn(move || {
while let Some(mut job) = pop_repo_job(&queue) {
let _repo_log_guard = use_status_area.then(|| {
logging::start_repo_log(job.repo_name.clone(), worker_id, repo_log_width)
});
let _repo_log_guard = use_repo_logs.then(logging::start_repo_log);
let repo_context = RepoSyncContext {
config,
mirror,
@@ -383,15 +379,6 @@ fn pop_repo_job(queue: &Arc<Mutex<VecDeque<RepoSyncJob>>>) -> Option<RepoSyncJob
.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 {
repo_name: String,
existing: Vec<EndpointRepo>,
+21 -8
View File
@@ -1,13 +1,26 @@
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~");
}
fn repo_log_context_is_inherited_by_parallel_workers() {
let _guard = start_repo_log();
#[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~");
crate::logln!("outer line");
crate::parallel::map(vec!["worker line"], 1, |line| {
crate::logln!("{line}");
Ok::<_, anyhow::Error>(())
})
.unwrap();
let lines = {
let context = current_repo_log_context().unwrap();
context
.inner
.lines
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
};
finish_repo_log();
assert_eq!(lines, vec!["outer line", "worker line"]);
}