use std::collections::BTreeSet;
use std::fs::{self, remove_dir_all, remove_file};
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use aisling::{
Color as AislingColor, Effect, EffectConfig, EffectKind, Gradient, GradientDirection, Loader,
LoaderConfig, LoaderKind, LoaderProgress,
};
use clap::Parser;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color as TuiColor, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use reqwest::StatusCode;
use reqwest::blocking::{Client, Response};
use serde::Deserialize;
use thiserror::Error;
const GITHUB_API: &str = "https://api.github.com";
const DEFAULT_PAGE_SIZE: usize = 100;
const BATCH_SIZE_CHOICES: [usize; 5] = [1, 2, 3, 6, 8];
#[derive(Parser, Clone)]
#[command(
name = "thesa",
version,
about = "Clone public GitHub repositories for a user or org"
)]
struct Cli {
target: Option<String>,
#[arg(short = 'o', long = "output", default_value = "./archives")]
output: PathBuf,
#[arg(short = 't', long = "token")]
token: Option<String>,
#[arg(long = "depth")]
depth: Option<u32>,
#[arg(short = 'c', long = "concurrency", default_value_t = 4)]
concurrency: usize,
#[arg(long = "skip-existing")]
skip_existing: bool,
#[arg(long = "filter")]
filter: Option<String>,
#[arg(long = "dry-run")]
dry_run: bool,
}
#[derive(Debug, Clone)]
enum Target {
Owner(String),
Repo { owner: String, repo: String },
}
#[derive(Debug, Clone)]
struct RepoItem {
name: String,
full_name: String,
clone_url: String,
}
#[derive(Debug, Deserialize)]
struct RepoResponse {
name: String,
#[serde(rename = "full_name")]
full_name: String,
#[serde(rename = "clone_url")]
clone_url: String,
private: bool,
}
#[derive(Debug, Clone)]
enum CloneStatus {
Cloned,
Skipped,
}
#[derive(Debug, Clone, Copy)]
enum BatchState {
Pending,
Running,
Done,
Skipped,
Failed,
}
#[derive(Debug)]
enum CloneAction {
Skipped,
Running(Child),
}
#[derive(Debug, Default)]
struct CloneSummary {
cloned: usize,
skipped: usize,
failed: Vec<String>,
}
#[derive(Error, Debug)]
enum GitArchiverError {
#[error("invalid target '{0}'")]
InvalidTarget(String),
#[error("GitHub API returned {status}: {body}")]
ApiError { status: u16, body: String },
#[error("GitHub entity '{0}' was not found")]
NotFound(String),
#[error("repo '{0}' is private, only public repos can be cloned")]
PrivateRepo(String),
#[error("output path '{0}' is not a directory")]
OutputNotDirectory(String),
#[error("'git' executable not found in PATH")]
MissingGit,
#[error("clone failed for '{repo}': {message}")]
CloneFailed { repo: String, message: String },
#[error("{0}")]
Message(String),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("request error: {0}")]
Request(#[from] reqwest::Error),
}
type Result<T> = std::result::Result<T, GitArchiverError>;
struct TerminalSession {
terminal: Terminal<CrosstermBackend<io::Stdout>>,
}
impl TerminalSession {
fn new() -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
Ok(Self { terminal })
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(
self.terminal.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen
);
let _ = self.terminal.show_cursor();
}
}
#[derive(Debug, Default)]
struct UiState {
show_details: bool,
abort_requested: bool,
panel_lines: usize,
}
fn main() {
if let Err(err) = run() {
eprintln!("error: {err}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let mut args = Cli::parse();
if args.token.is_none() {
args.token = std::env::var("GITHUB_TOKEN").ok();
}
let interactive = args.target.is_none();
let mut terminal_session = if interactive && io::stdout().is_terminal() {
Some(TerminalSession::new()?)
} else {
None
};
if interactive {
args.target = Some(if let Some(session) = terminal_session.as_mut() {
prompt_target_tui_inner(&mut session.terminal)?
} else {
prompt_target()?
});
}
let target = parse_target(args.target.as_deref().unwrap_or_default())?;
if args.output == PathBuf::from("./archives") {
args.output = default_output_path(&target);
}
let output = prepare_output_dir(&args.output)?;
let client = Client::builder()
.user_agent("thesa")
.build()
.map_err(GitArchiverError::Request)?;
let mut repos = if let Some(session) = terminal_session.as_mut() {
fetch_repositories_tui(
&mut session.terminal,
&client,
&target,
args.token.as_deref(),
)?
} else {
fetch_repositories_for_target(&client, &target, args.token.as_deref())?
};
if interactive {
repos = match &target {
Target::Owner(_) if terminal_session.is_some() => {
let session = terminal_session.as_mut().expect("checked above");
pick_repositories_tui_inner(&mut session.terminal, &repos, &mut args.concurrency)?
}
Target::Owner(_) => pick_repositories_interactively(&repos)?,
Target::Repo { .. } => repos,
};
} else if let Some(pattern) = args.filter.as_deref() {
repos.retain(|repo| repo.name.contains(pattern));
}
if repos.is_empty() {
drop(terminal_session);
println!("No repositories matched your request.");
return Ok(());
}
if args.dry_run {
drop(terminal_session);
println!("Dry run: {} repositories would be processed", repos.len());
for repo in &repos {
println!(" - {}", repo.full_name);
}
return Ok(());
}
let mut ui_state = UiState {
show_details: true,
..UiState::default()
};
let summary = if let Some(session) = terminal_session.as_mut() {
clone_repositories_ratatui_inner(
&mut session.terminal,
&repos,
&output,
&args,
&mut ui_state,
)?
} else if io::stdout().is_terminal() {
clone_repositories_ratatui(&repos, &output, &args, &mut ui_state)?
} else {
let (ui_tx, ui_rx) = mpsc::channel::<char>();
let _ui_thread = thread::spawn(move || {
let stdin = io::stdin();
loop {
let mut line = String::new();
if stdin.read_line(&mut line).is_err() || line.is_empty() {
break;
}
let mut chars = line.chars().filter(|c| !c.is_whitespace());
if let Some(command) = chars.next() {
if ui_tx.send(command).is_err() {
break;
}
}
}
});
if args.concurrency > 1 {
clone_repositories_batched(&repos, &output, &args, &ui_rx, &mut ui_state)?
} else {
clone_repositories_serial(&repos, &output, &args, &ui_rx, &mut ui_state)?
}
};
drop(terminal_session);
if ui_state.abort_requested {
println!("Aborted by user input");
}
println!(
"\nCompleted: {} cloned, {} skipped, {} failed",
summary.cloned,
summary.skipped,
summary.failed.len()
);
if !summary.failed.is_empty() {
eprintln!("Failures:");
for item in summary.failed {
eprintln!(" - {}", item);
}
return Err(GitArchiverError::Message(
"some repositories failed".to_string(),
));
}
Ok(())
}
fn prompt_target_tui_inner(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<String> {
let mut input = String::new();
let mut frame_index = 0usize;
let mut status = "enter GitHub owner or repo name".to_string();
loop {
terminal.draw(|frame| {
draw_target_tui(frame, &input, &status, frame_index);
})?;
frame_index = frame_index.wrapping_add(1);
if !event::poll(Duration::from_millis(50))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Esc => return Err(GitArchiverError::Message("aborted by user".to_string())),
KeyCode::Enter => {
let target = input.trim().to_string();
if target.is_empty() {
status = "repo name or owner is required".to_string();
continue;
}
if parse_target(&target).is_ok() {
return Ok(target);
}
status = "try an owner like octocat or a repo like octocat/Spoon-Knife".to_string();
}
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(ch) => {
input.push(ch);
}
_ => {}
}
}
}
fn pick_repositories_tui_inner(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
repos: &[RepoItem],
batch_size: &mut usize,
) -> Result<Vec<RepoItem>> {
if repos.is_empty() {
return Ok(vec![]);
}
let mut query = String::new();
let mut selected = BTreeSet::new();
let mut cursor = 0usize;
let mut frame_index = 0usize;
let mut batch_overlay = false;
let mut status =
"Space checks repos; Enter clones checked repos, or visible if none checked".to_string();
loop {
let filtered = filtered_repo_indices(repos, &query);
if cursor >= filtered.len() {
cursor = filtered.len().saturating_sub(1);
}
terminal.draw(|frame| {
draw_repo_picker_tui(
frame,
repos,
&filtered,
&selected,
cursor,
&query,
&status,
frame_index,
*batch_size,
batch_overlay,
);
})?;
frame_index = frame_index.wrapping_add(1);
if !event::poll(Duration::from_millis(50))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if batch_overlay {
match key.code {
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Enter => {
batch_overlay = false;
status = format!("batch size set to {}; Enter clones repos", *batch_size);
}
KeyCode::Char(ch) => {
if let Some(value) = batch_size_from_key(ch) {
*batch_size = value;
batch_overlay = false;
status = format!("batch size set to {value}; Enter clones repos");
}
}
KeyCode::Left | KeyCode::Up => {
*batch_size = previous_batch_size(*batch_size);
status = format!("batch size set to {}; Enter accepts", *batch_size);
}
KeyCode::Right | KeyCode::Down => {
*batch_size = next_batch_size(*batch_size);
status = format!("batch size set to {}; Enter accepts", *batch_size);
}
_ => {}
}
continue;
}
match key.code {
KeyCode::Esc => return Err(GitArchiverError::Message("aborted by user".to_string())),
KeyCode::Enter => {
let picked = if selected.is_empty() {
filtered
} else {
selected.iter().copied().collect::<Vec<_>>()
};
if picked.is_empty() {
status = "no repositories selected or visible".to_string();
continue;
}
return Ok(picked
.into_iter()
.map(|index| repos[index].clone())
.collect());
}
KeyCode::Up => cursor = cursor.saturating_sub(1),
KeyCode::Down => {
if cursor + 1 < filtered.len() {
cursor += 1;
}
}
KeyCode::Char(' ') => {
if let Some(repo_index) = filtered.get(cursor).copied() {
if !selected.insert(repo_index) {
selected.remove(&repo_index);
}
let checked = selected.len();
status = if checked == 0 {
"nothing checked; Enter clones visible repos".to_string()
} else {
format!("{checked} checked; Enter clones checked repos only")
};
}
}
KeyCode::Char('a') => {
for repo_index in &filtered {
selected.insert(*repo_index);
}
status = format!("checked {} visible repos", filtered.len());
}
KeyCode::Char('c') => {
selected.clear();
query.clear();
cursor = 0;
status = "cleared filter and selection".to_string();
}
KeyCode::Char('b') => {
batch_overlay = true;
status = "choose batch size: 1 2 3 6 8".to_string();
}
KeyCode::Backspace => {
query.pop();
cursor = 0;
}
KeyCode::Char(ch) => {
query.push(ch);
cursor = 0;
}
_ => {}
}
}
}
fn fetch_repositories_for_target(
client: &Client,
target: &Target,
token: Option<&str>,
) -> Result<Vec<RepoItem>> {
match target {
Target::Owner(owner) => match list_repos_for_owner(client, owner, token) {
Ok(items) => Ok(items),
Err(GitArchiverError::NotFound(_)) => list_repos_for_org(client, owner, token),
Err(other) => Err(other),
},
Target::Repo { owner, repo } => Ok(vec![fetch_single_repo(client, owner, repo, token)?]),
}
}
fn batch_size_from_key(ch: char) -> Option<usize> {
match ch {
'1' => Some(1),
'2' => Some(2),
'3' => Some(3),
'6' => Some(6),
'8' => Some(8),
_ => None,
}
}
fn previous_batch_size(current: usize) -> usize {
let index = BATCH_SIZE_CHOICES
.iter()
.position(|value| *value == current)
.unwrap_or(0);
BATCH_SIZE_CHOICES[index.saturating_sub(1)]
}
fn next_batch_size(current: usize) -> usize {
let index = BATCH_SIZE_CHOICES
.iter()
.position(|value| *value == current)
.unwrap_or(0);
BATCH_SIZE_CHOICES[(index + 1).min(BATCH_SIZE_CHOICES.len() - 1)]
}
fn fetch_repositories_tui(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
client: &Client,
target: &Target,
token: Option<&str>,
) -> Result<Vec<RepoItem>> {
let client = client.clone();
let target = target.clone();
let token = token.map(str::to_string);
let loading_label = match &target {
Target::Owner(owner) => format!("loading public repos for {owner}"),
Target::Repo { owner, repo } => format!("loading repo {owner}/{repo}"),
};
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = fetch_repositories_for_target(&client, &target, token.as_deref());
let _ = tx.send(result);
});
let mut tick = 0usize;
loop {
if let Ok(result) = rx.try_recv() {
return result;
}
terminal.draw(|frame| draw_loading_tui(frame, &loading_label, tick))?;
tick = tick.wrapping_add(1);
if event::poll(Duration::from_millis(80))? {
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Press && matches!(key.code, KeyCode::Esc) {
return Err(GitArchiverError::Message("aborted by user".to_string()));
}
}
}
}
fn filtered_repo_indices(repos: &[RepoItem], query: &str) -> Vec<usize> {
if query.trim().is_empty() {
return (0..repos.len()).collect();
}
let query = query.to_lowercase();
repos
.iter()
.enumerate()
.filter(|(_, repo)| {
repo.name.to_lowercase().contains(&query)
|| repo.full_name.to_lowercase().contains(&query)
})
.map(|(index, _)| index)
.collect()
}
fn draw_target_tui(frame: &mut Frame<'_>, input: &str, status: &str, tick: usize) {
let area = frame.area();
let prompt_label = "repo/owner> ";
if area.width < 24 || area.height < 8 {
frame.render_widget(
Paragraph::new(format!("{prompt_label}{input}\n{status}")),
area,
);
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(if area.height >= 32 { 22 } else { 3 }),
Constraint::Min(5),
Constraint::Length(2),
])
.split(area);
draw_thesa_header(frame, layout[0], tick, status, "interactive setup");
let body = Paragraph::new(Text::from(vec![
Line::from(vec![
Span::styled(prompt_label, prompt_style()),
Span::styled(input.to_string(), Style::default().fg(theme_white())),
]),
Line::from(""),
Line::from(vec![
Span::styled("examples ", Style::default().fg(theme_dim())),
Span::styled("octocat", Style::default().fg(theme_blue())),
Span::raw(" "),
Span::styled("octocat/Spoon-Knife", Style::default().fg(theme_green())),
Span::raw(" "),
Span::styled(
"https://github.com/owner/repo",
Style::default().fg(theme_red()),
),
]),
]))
.block(knott_block(" Target ", theme_blue()))
.wrap(Wrap { trim: false });
frame.render_widget(body, layout[1]);
draw_thesa_bottom_bar(frame, layout[2], status, &["Enter accept", "Esc quit"]);
let cursor_x = layout[1]
.x
.saturating_add(1)
.saturating_add(prompt_label.len() as u16)
.saturating_add(input.chars().count() as u16)
.min(layout[1].x + layout[1].width.saturating_sub(2));
frame.set_cursor_position((cursor_x, layout[1].y + 1));
}
fn draw_loading_tui(frame: &mut Frame<'_>, label: &str, tick: usize) {
let area = frame.area();
if area.width < 30 || area.height < 8 {
frame.render_widget(Paragraph::new(format!("thesa\n{label}")), area);
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(if area.height >= 32 { 22 } else { 3 }),
Constraint::Min(5),
Constraint::Length(2),
])
.split(area);
draw_thesa_header(frame, layout[0], tick, label, "loading");
let loader = Loader::with_config(
LoaderKind::Tqdm,
LoaderConfig::default()
.with_size(layout[1].width.saturating_sub(4).max(12) as usize, 1)
.with_label("github")
.with_unit("repo")
.with_fraction(false),
);
let loader_line = loader.line(
tick,
LoaderProgress::from_counts((tick % 97) as u64 + 1, 100),
);
let effect = aisling_effect_text(EffectKind::Wipe, tick, layout[1].width as usize, 5);
let panel = Paragraph::new(Text::from(vec![
section_line("Loading"),
Line::styled(label.to_string(), Style::default().fg(theme_white())),
Line::raw(loader_line),
Line::from(""),
Line::raw(effect),
]))
.block(knott_block(" Aisling Loader ", theme_blue()))
.wrap(Wrap { trim: false });
frame.render_widget(panel, layout[1]);
draw_thesa_bottom_bar(frame, layout[2], label, &["Esc cancel"]);
}
#[allow(clippy::too_many_arguments)]
fn draw_repo_picker_tui(
frame: &mut Frame<'_>,
repos: &[RepoItem],
filtered: &[usize],
selected: &BTreeSet<usize>,
cursor: usize,
query: &str,
status: &str,
tick: usize,
batch_size: usize,
batch_overlay: bool,
) {
let area = frame.area();
if area.width < 28 || area.height < 8 {
frame.render_widget(
Paragraph::new(format!(
"repos {} visible / {} total\nfilter> {}\n{}",
filtered.len(),
repos.len(),
query,
status,
)),
area,
);
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(if area.height >= 34 { 22 } else { 3 }),
Constraint::Min(8),
Constraint::Length(2),
])
.split(area);
draw_thesa_header(frame, layout[0], tick, status, "repo picker");
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints(if layout[1].width >= 96 {
[Constraint::Percentage(72), Constraint::Percentage(28)]
} else {
[Constraint::Percentage(100), Constraint::Percentage(0)]
})
.split(layout[1]);
let clone_scope = if selected.is_empty() {
format!("visible: {}", filtered.len())
} else {
format!("checked only: {}", selected.len())
};
let items = filtered
.iter()
.map(|repo_index| {
let repo = &repos[*repo_index];
let marker = if selected.contains(repo_index) {
"[x]"
} else {
"[ ]"
};
ListItem::new(Line::from(vec![
Span::styled(
marker,
Style::default()
.fg(theme_green())
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(repo.full_name.clone(), Style::default().fg(theme_white())),
]))
})
.collect::<Vec<_>>();
let mut state = ListState::default();
if !filtered.is_empty() {
state.select(Some(cursor));
}
let list_title = format!(
" Repositories | filter: {} | {} | batch: {} ",
if query.is_empty() { "all" } else { query },
clone_scope,
batch_size,
);
let list = List::new(items)
.block(knott_block(&list_title, theme_blue()))
.highlight_style(
Style::default()
.bg(TuiColor::White)
.fg(TuiColor::Black)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
frame.render_stateful_widget(list, body[0], &mut state);
if body[1].width > 0 {
let effect = aisling_effect_text(EffectKind::Matrix, tick, body[1].width as usize, 6);
let sidebar = Paragraph::new(Text::from(vec![
section_line("Controls"),
help_pair_line("type", "filter public repos"),
help_pair_line("Space", "check/uncheck repo"),
help_pair_line("a", "check visible"),
help_pair_line("b", "batch size overlay"),
help_pair_line("c", "clear"),
help_pair_line("Enter", "checked only, or visible"),
Line::from(""),
section_line("Aisling Matrix"),
Line::raw(effect),
]))
.block(knott_block(" Knott-style panel ", theme_dim()))
.wrap(Wrap { trim: false });
frame.render_widget(sidebar, body[1]);
}
draw_thesa_bottom_bar(
frame,
layout[2],
status,
&[
"Up/Down move",
"Space check",
"b batch",
"Enter clone",
"Esc quit",
],
);
if batch_overlay {
draw_batch_overlay(frame, area, batch_size);
}
}
fn draw_batch_overlay(frame: &mut Frame<'_>, area: Rect, batch_size: usize) {
let width = area.width.min(58).max(30.min(area.width));
let height = area.height.min(11).max(7.min(area.height));
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
let overlay = Rect::new(x, y, width, height);
frame.render_widget(Clear, overlay);
let mut choice_spans = Vec::new();
for value in BATCH_SIZE_CHOICES {
if !choice_spans.is_empty() {
choice_spans.push(Span::raw(" "));
}
let style = if value == batch_size {
Style::default()
.bg(TuiColor::White)
.fg(TuiColor::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme_blue())
.add_modifier(Modifier::BOLD)
};
choice_spans.push(Span::styled(format!(" {value} "), style));
}
let panel = Paragraph::new(Text::from(vec![
section_line("Batch Size"),
Line::styled(
"How many repos to pull at once",
Style::default().fg(theme_dim()),
),
Line::from(""),
Line::from(choice_spans),
Line::from(""),
help_pair_line("1/2/3/6/8", "select and close"),
help_pair_line("Left/Right", "cycle choices"),
help_pair_line("b / Enter", "close overlay"),
]))
.block(knott_block(" Batch Options ", theme_red()))
.wrap(Wrap { trim: false });
frame.render_widget(panel, overlay);
}
fn draw_thesa_header(frame: &mut Frame<'_>, area: Rect, tick: usize, status: &str, mode: &str) {
if area.height == 0 {
return;
}
if area.height < 22 {
let mut spans = vec![Span::styled(
"[ thesa ] t h e s a",
Style::default()
.fg(theme_white())
.add_modifier(Modifier::BOLD),
)];
append_status_chip(&mut spans, "mode", mode, theme_blue());
append_status_chip(&mut spans, "state", status, theme_green());
let header = Paragraph::new(Text::from(vec![Line::from(spans)]))
.block(knott_block(" [ thesa ] ", theme_blue()));
frame.render_widget(header, area);
return;
}
let mut lines = thesa_banner_lines();
let mut primary = Vec::new();
append_status_chip(&mut primary, "mode", mode, theme_blue());
append_status_chip(&mut primary, "state", status, theme_green());
append_status_chip(&mut primary, "effect", "aisling", theme_red());
append_status_chip(&mut primary, "frame", tick.to_string(), theme_dim());
lines.push(Line::from(primary));
let header = Paragraph::new(Text::from(lines))
.block(knott_block(" [ thesa ] t h e s a ", theme_blue()))
.alignment(Alignment::Left);
frame.render_widget(header, area);
}
fn draw_thesa_bottom_bar(frame: &mut Frame<'_>, area: Rect, status: &str, keys: &[&str]) {
if area.height == 0 {
return;
}
let mut status_spans = vec![
Span::styled("status: ", Style::default().fg(theme_dim())),
Span::styled(status.to_string(), Style::default().fg(theme_white())),
];
append_status_chip(&mut status_spans, "ui", "ratatui", theme_blue());
append_status_chip(&mut status_spans, "fx", "aisling", theme_green());
let key_line = Line::from(
keys.iter()
.flat_map(|key| {
let (head, detail) = key.split_once(' ').unwrap_or((key, ""));
vec![
Span::styled(head.to_string(), key_style()),
Span::raw(if detail.is_empty() {
" ".to_string()
} else {
format!(" {detail} ")
}),
]
})
.collect::<Vec<_>>(),
);
let lines = if area.height <= 1 {
vec![Line::from(status_spans)]
} else {
vec![Line::from(status_spans), key_line]
};
frame.render_widget(
Paragraph::new(Text::from(lines)).style(Style::default().fg(TuiColor::Gray)),
area,
);
}
fn knott_block<'a>(title: &'a str, color: TuiColor) -> Block<'a> {
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(color))
}
fn theme_white() -> TuiColor {
TuiColor::White
}
fn theme_red() -> TuiColor {
TuiColor::Rgb(210, 35, 45)
}
fn theme_blue() -> TuiColor {
TuiColor::Rgb(75, 145, 220)
}
fn theme_green() -> TuiColor {
TuiColor::Rgb(70, 190, 125)
}
fn theme_dim() -> TuiColor {
TuiColor::Rgb(105, 115, 130)
}
fn prompt_style() -> Style {
Style::default()
.fg(theme_blue())
.add_modifier(Modifier::BOLD)
}
fn key_style() -> Style {
Style::default()
.bg(TuiColor::White)
.fg(TuiColor::Black)
.add_modifier(Modifier::BOLD)
}
fn section_line(label: &str) -> Line<'static> {
Line::from(Span::styled(
label.to_string(),
Style::default()
.fg(theme_white())
.add_modifier(Modifier::BOLD),
))
}
fn help_pair_line(head: &str, detail: &str) -> Line<'static> {
Line::from(vec![
Span::styled(
format!("{head:<12}"),
Style::default()
.fg(theme_blue())
.add_modifier(Modifier::BOLD),
),
Span::styled(detail.to_string(), Style::default().fg(theme_dim())),
])
}
fn append_status_chip(
spans: &mut Vec<Span<'static>>,
label: &str,
value: impl Into<String>,
color: TuiColor,
) {
if !spans.is_empty() {
spans.push(Span::raw(" "));
}
spans.push(Span::styled("[", Style::default().fg(theme_dim())));
spans.push(Span::styled(
label.to_string(),
Style::default().fg(theme_dim()),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
value.into(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled("]", Style::default().fg(theme_dim())));
}
fn thesa_banner_lines() -> Vec<Line<'static>> {
const BANNER: [&str; 19] = [
"/ ",
" #/ ",
" # ## ",
" ## ## ",
" ## ## ",
" ######## ## /## /## /### /### ",
"######## ## / ### / ### / #### / / ### / ",
" ## ##/ ### / ### ## ###/ / ###/ ",
" ## ## ## ## ### #### ## ## ",
" ## ## ## ######## ### ## ## ",
" ## ## ## ####### ### ## ## ",
" ## ## ## ## ### ## ## ",
" ## ## ## #### / /### ## ## /# ",
" ## ## ## ######/ / #### / ####/ ## ",
" ## ## ## ##### ###/ ### ## ",
" / ",
" / ",
" / ",
" / ",
];
BANNER
.iter()
.enumerate()
.map(|(index, line)| {
let color = match index % 4 {
0 => theme_white(),
1 => theme_blue(),
2 => theme_red(),
_ => theme_dim(),
};
Line::from(Span::styled(
(*line).to_string(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
})
.collect()
}
fn prompt_target() -> Result<String> {
loop {
let value = prompt_line("GitHub target [owner or owner/repo]: ")?;
if value.is_empty() {
println!("Target is required.");
continue;
}
if parse_target(&value).is_ok() {
return Ok(value);
}
println!(
"Invalid target. Examples: torvalds, torvalds/linux, https://github.com/torvalds/linux"
);
}
}
fn default_output_path(target: &Target) -> PathBuf {
let slug = match target {
Target::Owner(owner) => owner.clone(),
Target::Repo { repo, .. } => repo.clone(),
};
PathBuf::from("./archives").join(slug)
}
fn prompt_line(prompt: &str) -> Result<String> {
print!("{prompt}");
io::stdout().flush()?;
let mut value = String::new();
io::stdin()
.read_line(&mut value)
.map_err(GitArchiverError::Io)?;
Ok(value.trim().to_string())
}
fn pick_repositories_interactively(repos: &[RepoItem]) -> Result<Vec<RepoItem>> {
if repos.is_empty() {
return Ok(vec![]);
}
let snapshot: Vec<RepoItem> = repos.to_vec();
loop {
println!("Found {} public repositories", snapshot.len());
let query = prompt_line("Search public repos (blank = all, q = quit): ")?;
if query.eq_ignore_ascii_case("q") {
return Err(GitArchiverError::Message("aborted by user".to_string()));
}
let filtered = if query.is_empty() {
snapshot.clone()
} else {
let normalized_query = query.to_lowercase();
snapshot
.iter()
.filter(|repo| repo.name.to_lowercase().contains(&normalized_query))
.cloned()
.collect::<Vec<_>>()
};
if filtered.is_empty() {
println!("No matches for '{query}'.");
continue;
}
for (index, repo) in filtered.iter().enumerate() {
println!("{:>3}. {}", index + 1, repo.full_name);
}
let selection = prompt_line("Clone [A]ll, specific indexes (1,3-5) or [q] to quit [a]: ")?;
if selection.is_empty() || selection.eq_ignore_ascii_case("a") {
return Ok(filtered);
}
if selection.eq_ignore_ascii_case("q") {
return Err(GitArchiverError::Message("aborted by user".to_string()));
}
match parse_indices(&selection, filtered.len()) {
Ok(indices) => {
let selected = indices
.into_iter()
.map(|idx| filtered[idx].clone())
.collect::<Vec<_>>();
return Ok(selected);
}
Err(message) => {
println!("{message}");
}
}
}
}
fn parse_indices(input: &str, max_len: usize) -> std::result::Result<Vec<usize>, String> {
let mut unique = Vec::new();
for token in input.split(',') {
let token = token.trim();
if token.is_empty() {
continue;
}
if let Some((start, end)) = token.split_once('-') {
let start = start
.trim()
.parse::<usize>()
.map_err(|_| "Invalid start of range".to_string())?;
let end = end
.trim()
.parse::<usize>()
.map_err(|_| "Invalid end of range".to_string())?;
if start == 0 || end == 0 || start > end {
return Err(
"Invalid range: indexes are 1-based and must be start <= end".to_string(),
);
}
let (start, end) = (start - 1, end - 1);
if end >= max_len {
return Err(format!(
"Range end {input} is above result count {max_len}",
input = end + 1,
));
}
for index in start..=end {
if unique.contains(&index) {
continue;
}
unique.push(index);
}
continue;
}
let index = token
.parse::<usize>()
.map_err(|_| format!("Invalid index '{token}'"))?;
if index == 0 {
return Err("Indexes are 1-based, use 1 or greater".to_string());
}
let index = index - 1;
if index >= max_len {
return Err(format!(
"Index {} is above result count {}",
index + 1,
max_len
));
}
if !unique.contains(&index) {
unique.push(index);
}
}
if unique.is_empty() {
return Err("No valid indexes provided".to_string());
}
Ok(unique)
}
fn apply_ui_events(ui_rx: &mpsc::Receiver<char>, ui_state: &mut UiState) {
while let Ok(command) = ui_rx.try_recv() {
match command {
't' | 'T' => {
ui_state.show_details = !ui_state.show_details;
}
'q' | 'Q' => {
ui_state.abort_requested = true;
}
_ => {}
}
}
}
fn thesa_gradient() -> Gradient {
Gradient::new(
vec![
AislingColor::rgb(0, 255, 210),
AislingColor::rgb(142, 92, 255),
AislingColor::rgb(255, 80, 210),
AislingColor::rgb(255, 196, 92),
],
36,
)
}
fn paint(text: impl AsRef<str>, color: AislingColor) -> String {
let color = fg(color);
format!("{color}{}\x1b[0m", text.as_ref())
}
fn paint_bold(text: impl AsRef<str>, color: AislingColor) -> String {
let color = fg(color);
format!("\x1b[1m{color}{}\x1b[0m", text.as_ref())
}
fn fg(color: AislingColor) -> String {
format!("\x1b[38;2;{};{};{}m", color.r, color.g, color.b)
}
fn clone_repositories_ratatui(
repos: &[RepoItem],
output: &Path,
args: &Cli,
ui_state: &mut UiState,
) -> Result<CloneSummary> {
let mut session = TerminalSession::new()?;
clone_repositories_ratatui_inner(&mut session.terminal, repos, output, args, ui_state)
}
fn clone_repositories_ratatui_inner(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
repos: &[RepoItem],
output: &Path,
args: &Cli,
ui_state: &mut UiState,
) -> Result<CloneSummary> {
let batch_size = args.concurrency.max(1);
let total = repos.len();
let total_batches = total.div_ceil(batch_size);
let mut summary = CloneSummary::default();
let mut effect_kind = EffectKind::Matrix;
let started_at = Instant::now();
let mut tick = 0usize;
let mut batch_index = 1;
while batch_index <= total {
let batch_end = (batch_index + batch_size - 1).min(total);
let batch = &repos[batch_index - 1..batch_end];
let names = batch
.iter()
.map(|repo| repo.full_name.clone())
.collect::<Vec<_>>();
let mut state = vec![BatchState::Pending; batch.len()];
let (tx, rx) = mpsc::channel::<(usize, BatchEvent)>();
let mut handles = Vec::new();
for (offset, repo) in batch.iter().enumerate() {
let repo = repo.clone();
let output = output.to_path_buf();
let args = args.clone();
let tx = tx.clone();
handles.push((
repo.full_name.clone(),
thread::spawn(move || {
let started = tx.send((offset, BatchEvent::Started));
if started.is_err() {
return;
}
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
clone_repository(&repo, &output, &args)
}));
let event = match outcome {
Ok(Ok(CloneStatus::Cloned)) => BatchEvent::Result(BatchResult::Done),
Ok(Ok(CloneStatus::Skipped)) => BatchEvent::Result(BatchResult::Skipped),
Ok(Err(err)) => BatchEvent::Result(BatchResult::Error(err.to_string())),
Err(_) => BatchEvent::Result(BatchResult::Error(
"worker thread panicked while cloning".to_string(),
)),
};
let _ = tx.send((offset, event));
}),
));
}
drop(tx);
let mut completed = 0;
while completed < batch.len() {
while event::poll(Duration::from_millis(0))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => ui_state.abort_requested = true,
KeyCode::Char('t') => ui_state.show_details = !ui_state.show_details,
KeyCode::Char('m') => effect_kind = EffectKind::Matrix,
KeyCode::Char('b') => effect_kind = EffectKind::Burn,
KeyCode::Char('w') => effect_kind = EffectKind::Wipe,
_ => {}
}
}
}
while let Ok((offset, event)) = rx.try_recv() {
match event {
BatchEvent::Started => state[offset] = BatchState::Running,
BatchEvent::Result(BatchResult::Done) => {
state[offset] = BatchState::Done;
summary.cloned += 1;
completed += 1;
}
BatchEvent::Result(BatchResult::Skipped) => {
state[offset] = BatchState::Skipped;
summary.skipped += 1;
completed += 1;
}
BatchEvent::Result(BatchResult::Error(message)) => {
state[offset] = BatchState::Failed;
summary
.failed
.push(format!("{}: {}", names[offset], message));
completed += 1;
}
}
}
terminal.draw(|frame| {
render_clone_dashboard(
frame,
&names,
&state,
&summary,
batch_index,
batch_size,
total,
total_batches,
tick,
started_at.elapsed(),
effect_kind,
ui_state,
)
})?;
tick = tick.wrapping_add(1);
if completed < batch.len() {
thread::sleep(Duration::from_millis(80));
}
}
for (repo_name, handle) in handles {
if handle.join().is_err() {
summary
.failed
.push(format!("{repo_name}: worker thread panicked"));
}
}
terminal.draw(|frame| {
render_clone_dashboard(
frame,
&names,
&state,
&summary,
batch_index,
batch_size,
total,
total_batches,
tick,
started_at.elapsed(),
effect_kind,
ui_state,
)
})?;
if ui_state.abort_requested {
break;
}
batch_index = batch_end + 1;
}
Ok(summary)
}
#[allow(clippy::too_many_arguments)]
fn render_clone_dashboard(
frame: &mut Frame,
names: &[String],
state: &[BatchState],
summary: &CloneSummary,
batch_index: usize,
batch_size: usize,
total_repos: usize,
total_batches: usize,
tick: usize,
elapsed: Duration,
effect_kind: EffectKind,
ui_state: &UiState,
) {
let area = frame.area();
let overall_done = summary.cloned + summary.skipped + summary.failed.len();
let status = if ui_state.abort_requested {
"stopping after current batch"
} else {
"cloning"
};
if area.width < 36 || area.height < 10 {
let loader = Loader::with_config(
LoaderKind::Tqdm,
LoaderConfig::default()
.with_size(area.width.saturating_sub(4) as usize, 1)
.with_label("thesa")
.with_unit("repo")
.with_fraction(true),
);
let line = loader.line(
tick,
LoaderProgress::from_counts(overall_done as u64, total_repos as u64),
);
frame.render_widget(
Paragraph::new(Text::from(vec![
Line::styled(
"thesa",
Style::default()
.fg(theme_white())
.add_modifier(Modifier::BOLD),
),
Line::raw(line),
Line::styled(status.to_string(), Style::default().fg(theme_dim())),
]))
.block(knott_block(" thesa ", theme_blue())),
area,
);
return;
}
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(if area.height >= 34 { 22 } else { 3 }),
Constraint::Min(8),
Constraint::Length(2),
])
.split(area);
draw_thesa_header(frame, root[0], tick, status, "clone dashboard");
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints(if root[1].width >= 104 {
[Constraint::Percentage(68), Constraint::Percentage(32)]
} else {
[Constraint::Percentage(100), Constraint::Percentage(0)]
})
.split(root[1]);
let left = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(5), Constraint::Min(5)])
.split(body[0]);
let overall_loader = Loader::with_config(
LoaderKind::Tqdm,
LoaderConfig::default()
.with_size(left[0].width.saturating_sub(4).max(12) as usize, 1)
.with_label("thesa")
.with_unit("repo")
.with_fraction(true),
);
let overall_line = overall_loader.line(
tick,
LoaderProgress::from_counts(overall_done as u64, total_repos as u64),
);
let mut chips = Vec::new();
append_status_chip(
&mut chips,
"cloned",
summary.cloned.to_string(),
theme_green(),
);
append_status_chip(
&mut chips,
"skipped",
summary.skipped.to_string(),
TuiColor::Yellow,
);
append_status_chip(
&mut chips,
"failed",
summary.failed.len().to_string(),
theme_red(),
);
append_status_chip(
&mut chips,
"elapsed",
format!("{}s", elapsed.as_secs()),
theme_dim(),
);
let current_batch = ((batch_index - 1) / batch_size) + 1;
let stats = Paragraph::new(Text::from(vec![
Line::from(chips),
Line::raw(overall_line),
Line::styled(
format!("batch {current_batch}/{total_batches} concurrency {batch_size}"),
Style::default().fg(theme_dim()),
),
]))
.block(knott_block(" Progress ", theme_green()));
frame.render_widget(stats, left[0]);
let repo_loader = Loader::with_config(
LoaderKind::Tqdm,
LoaderConfig::default()
.with_size(left[1].width.saturating_sub(34).max(10) as usize, 1)
.with_label("repo")
.with_unit("step")
.with_fraction(true),
);
let mut rows = Vec::new();
if ui_state.show_details {
for (index, (name, repo_state)) in names.iter().zip(state.iter()).enumerate() {
rows.push(repo_loader_row(
&repo_loader,
tick + index,
name,
*repo_state,
));
}
} else {
rows.push(Line::styled(
"details hidden; press t to show concurrent repo loaders",
Style::default().fg(theme_dim()),
));
}
let repos = Paragraph::new(Text::from(rows))
.block(knott_block(
" Concurrent Pulls | Aisling Tqdm ",
theme_blue(),
))
.wrap(Wrap { trim: false });
frame.render_widget(repos, left[1]);
if body[1].width > 0 {
let effect_height = body[1].height.saturating_sub(12).max(4) as usize;
let effect_text =
aisling_effect_text(effect_kind, tick, body[1].width as usize, effect_height);
let mut side_lines = vec![
section_line("Controls"),
help_pair_line("q / Esc", "stop after current batch"),
help_pair_line("t", "toggle repo rows"),
help_pair_line("m", "matrix effect"),
help_pair_line("b", "burn effect"),
help_pair_line("w", "wipe effect"),
Line::from(""),
section_line(&format!("Aisling {}", effect_kind.name())),
];
side_lines.extend(effect_text.lines().map(|line| Line::raw(line.to_string())));
let side = Paragraph::new(Text::from(side_lines))
.block(knott_block(" Effects ", theme_red()))
.wrap(Wrap { trim: false });
frame.render_widget(side, body[1]);
}
draw_thesa_bottom_bar(
frame,
root[2],
status,
&["q stop", "t details", "m matrix", "b burn", "w wipe"],
);
}
fn aisling_effect_text(kind: EffectKind, tick: usize, width: usize, height: usize) -> String {
let effect = Effect::with_config(
kind,
"thesa\npublic github repo collector",
EffectConfig::default()
.with_duration(72)
.with_seed(17)
.with_gradient(thesa_gradient(), GradientDirection::Diagonal)
.with_canvas_size(width.saturating_sub(2).max(20), height.max(3)),
);
effect
.iter()
.nth(tick % 72)
.map(|frame| frame.to_plain_string())
.unwrap_or_else(|| "thesa".to_string())
}
fn repo_loader_row(loader: &Loader, tick: usize, name: &str, state: BatchState) -> Line<'static> {
let done = matches!(
state,
BatchState::Done | BatchState::Skipped | BatchState::Failed
);
let loader_line = loader.line(tick, LoaderProgress::from_counts(done as u64, 1));
let style = match state {
BatchState::Pending => Style::default().fg(TuiColor::DarkGray),
BatchState::Running => Style::default().fg(TuiColor::LightMagenta),
BatchState::Done => Style::default().fg(TuiColor::Cyan),
BatchState::Skipped => Style::default().fg(TuiColor::Yellow),
BatchState::Failed => Style::default().fg(TuiColor::Red),
};
Line::from(vec![
Span::styled(
format!("{:<7}", state.label()),
style.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw(loader_line),
Span::raw(" "),
Span::styled(name.to_string(), style),
])
}
fn clone_repositories_serial(
repos: &[RepoItem],
output: &Path,
args: &Cli,
ui_rx: &mpsc::Receiver<char>,
ui_state: &mut UiState,
) -> Result<CloneSummary> {
let spinner = Loader::with_config(
LoaderKind::Tqdm,
LoaderConfig::default()
.with_size(54, 1)
.with_label("thesa")
.with_unit("repo")
.with_fraction(true),
);
let mut stdout = io::stdout();
let mut summary = CloneSummary::default();
let total = repos.len();
for (index, repo) in repos.iter().enumerate() {
if ui_state.abort_requested {
break;
}
apply_ui_events(ui_rx, ui_state);
match clone_repository_with_progress(
repo,
output,
args,
&mut stdout,
&spinner,
index + 1,
total,
) {
Ok(CloneStatus::Cloned) => summary.cloned += 1,
Ok(CloneStatus::Skipped) => summary.skipped += 1,
Err(err) => summary.failed.push(format!("{}: {}", repo.full_name, err)),
}
}
Ok(summary)
}
fn clone_repositories_batched(
repos: &[RepoItem],
output: &Path,
args: &Cli,
ui_rx: &mpsc::Receiver<char>,
ui_state: &mut UiState,
) -> Result<CloneSummary> {
let batch_size = args.concurrency.max(1);
let total = repos.len();
let mut summary = CloneSummary::default();
let mut batch_index = 1;
let total_batches = total.div_ceil(batch_size);
let mut stop_after_batch = false;
while batch_index <= total {
let batch_end = (batch_index + batch_size - 1).min(total);
let batch = &repos[batch_index - 1..batch_end];
let batch_positions: Vec<String> =
batch.iter().map(|repo| repo.full_name.clone()).collect();
let (tx, rx) = mpsc::channel::<(usize, BatchEvent)>();
println!(
"Starting batch {}/{} ({})...",
(batch_index - 1) / batch_size + 1,
total_batches,
batch.len()
);
let mut state = vec![BatchState::Pending; batch.len()];
apply_ui_events(ui_rx, ui_state);
if ui_state.abort_requested {
break;
}
let mut handles = Vec::new();
for (offset, repo) in batch.iter().enumerate() {
let repo = repo.clone();
let output = output.to_path_buf();
let args = args.clone();
let position = batch_index + offset;
let tx = tx.clone();
handles.push((
position,
repo.full_name.clone(),
thread::spawn(move || {
let started = tx.send((offset, BatchEvent::Started));
if started.is_err() {
return;
}
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
clone_repository(&repo, &output, &args)
}));
let event = match outcome {
Ok(Ok(CloneStatus::Cloned)) => BatchEvent::Result(BatchResult::Done),
Ok(Ok(CloneStatus::Skipped)) => BatchEvent::Result(BatchResult::Skipped),
Ok(Err(err)) => BatchEvent::Result(BatchResult::Error(err.to_string())),
Err(_) => BatchEvent::Result(BatchResult::Error(
"worker thread panicked while cloning".to_string(),
)),
};
let _ = tx.send((offset, event));
}),
));
}
drop(tx);
let mut completed = 0;
while completed < batch.len() {
let mut progress_updated = false;
apply_ui_events(ui_rx, ui_state);
if ui_state.abort_requested {
stop_after_batch = true;
}
while let Ok((offset, event)) = rx.try_recv() {
match event {
BatchEvent::Started => {
state[offset] = BatchState::Running;
progress_updated = true;
}
BatchEvent::Result(BatchResult::Done) => {
state[offset] = BatchState::Done;
summary.cloned += 1;
completed += 1;
progress_updated = true;
}
BatchEvent::Result(BatchResult::Skipped) => {
state[offset] = BatchState::Skipped;
summary.skipped += 1;
completed += 1;
progress_updated = true;
}
BatchEvent::Result(BatchResult::Error(message)) => {
state[offset] = BatchState::Failed;
summary
.failed
.push(format!("{}: {}", batch_positions[offset], message));
completed += 1;
progress_updated = true;
}
}
}
if progress_updated {
render_batch_state(
&mut io::stdout(),
(batch_index - 1) / batch_size + 1,
total_batches,
batch_positions.as_slice(),
state.as_slice(),
ui_state.show_details,
&mut ui_state.panel_lines,
batch.len(),
total,
)?;
}
if completed < batch.len() {
thread::sleep(Duration::from_millis(100));
}
}
for (position, repo_name, handle) in handles {
if handle.join().is_err() {
summary.failed.push(format!(
"{repo_name} ({position}/{total}): worker thread panicked"
));
}
}
if stop_after_batch {
break;
}
batch_index = batch_end + 1;
}
Ok(summary)
}
#[derive(Debug, Clone)]
enum BatchResult {
Done,
Skipped,
Error(String),
}
#[derive(Debug, Clone)]
enum BatchEvent {
Started,
Result(BatchResult),
}
fn render_batch_state(
stdout: &mut io::Stdout,
batch_number: usize,
total_batches: usize,
names: &[String],
state: &[BatchState],
show_details: bool,
panel_lines: &mut usize,
batch_size: usize,
total_repos: usize,
) -> io::Result<()> {
if !stdout.is_terminal() {
return Ok(());
}
let mut done = 0;
let mut skipped = 0;
let mut failed = 0;
let mut running = 0;
let mut pending = 0;
for entry in state {
match entry {
BatchState::Done => done += 1,
BatchState::Skipped => skipped += 1,
BatchState::Failed => failed += 1,
BatchState::Running => running += 1,
BatchState::Pending => pending += 1,
}
}
let completed = done + skipped + failed;
let loader = Loader::with_config(
LoaderKind::SynthGrid,
LoaderConfig::default()
.with_size(58, 1)
.with_label("thesa")
.with_unit("repo")
.with_fraction(true)
.with_gradient(thesa_gradient(), GradientDirection::Horizontal),
);
let loader_line = loader
.frame(
completed + running,
LoaderProgress::from_counts(completed as u64, batch_size as u64),
)
.to_ansi_string();
let cyan = AislingColor::rgb(0, 255, 210);
let violet = AislingColor::rgb(142, 92, 255);
let amber = AislingColor::rgb(255, 196, 92);
let rose = AislingColor::rgb(255, 80, 210);
let slate = AislingColor::rgb(132, 150, 168);
let mut lines = vec![
paint(
"+--------------------------------------------------------------------+",
violet,
),
format!(
"| {} {} | {} |",
paint_bold("THESA", cyan),
paint("public-repo collector", slate),
paint(
format!("batch {batch_number}/{total_batches} total {total_repos}"),
amber,
)
),
paint(
"+--------------------------------------------------------------------+",
violet,
),
format!("| {} |", loader_line.trim_end()),
format!(
"| {} {} {} {} {} {} {} {} {} {} |",
paint("done", cyan),
done,
paint("skip", amber),
skipped,
paint("fail", rose),
failed,
paint("run", violet),
running,
paint("wait", slate),
pending,
),
format!(
"| {} |",
paint(
"keys: t + Enter toggles detail, q + Enter stops after batch",
slate
)
),
paint(
"+--------------------------------------------------------------------+",
violet,
),
];
if show_details {
for (name, repo_state) in names.iter().zip(state.iter()) {
lines.push(format!(" {} {}", repo_state.icon(), name));
}
} else {
lines.push(paint(
" detail hidden; press t + Enter to show repos",
slate,
));
}
lines.push(String::new());
if *panel_lines > 0 {
write!(stdout, "\x1b[{}A", *panel_lines)?;
}
let written_lines = (*panel_lines).max(lines.len());
for index in 0..written_lines {
let line = lines.get(index).map(String::as_str).unwrap_or("");
write!(stdout, "\x1b[2K\r{line}\n")?;
}
*panel_lines = written_lines;
stdout.flush()?;
Ok(())
}
impl BatchState {
fn label(self) -> &'static str {
match self {
BatchState::Pending => "queued",
BatchState::Running => "pulling",
BatchState::Done => "done",
BatchState::Skipped => "skip",
BatchState::Failed => "failed",
}
}
fn icon(self) -> &'static str {
match self {
BatchState::Pending => " [ ]",
BatchState::Running => "[-x]",
BatchState::Done => " [*]",
BatchState::Skipped => " [/]",
BatchState::Failed => " [!]",
}
}
}
fn parse_target(input: &str) -> Result<Target> {
let cleaned = input.trim().trim_end_matches(".git");
if cleaned.is_empty() {
return Err(GitArchiverError::InvalidTarget(input.to_string()));
}
if let Some(path) = cleaned.strip_prefix("https://github.com/") {
return parse_owner_or_repo_path(path);
}
if let Some(path) = cleaned.strip_prefix("http://github.com/") {
return parse_owner_or_repo_path(path);
}
if let Some(path) = cleaned.strip_prefix("git@github.com:") {
return parse_owner_or_repo_path(path);
}
parse_owner_or_repo_path(cleaned)
}
fn parse_owner_or_repo_path(input: &str) -> Result<Target> {
let parts: Vec<&str> = input
.trim_matches('/')
.split('/')
.filter(|part| !part.is_empty())
.collect();
if parts.is_empty() {
return Err(GitArchiverError::InvalidTarget(input.to_string()));
}
if parts.len() == 1 {
return Ok(Target::Owner(parts[0].to_string()));
}
Ok(Target::Repo {
owner: parts[0].to_string(),
repo: parts[1].to_string(),
})
}
fn prepare_output_dir(output: &Path) -> Result<PathBuf> {
if output.exists() && !output.is_dir() {
return Err(GitArchiverError::OutputNotDirectory(
output.display().to_string(),
));
}
fs::create_dir_all(output)?;
Ok(output.to_path_buf())
}
fn with_auth(
mut request: reqwest::blocking::RequestBuilder,
token: Option<&str>,
) -> reqwest::blocking::RequestBuilder {
request = request.header("Accept", "application/vnd.github+json");
if let Some(token) = token {
request = request.bearer_auth(token);
}
request
}
fn parse_api_error(response: Response) -> GitArchiverError {
let status = response.status();
let body = response
.text()
.unwrap_or_else(|_| "<unable to read body>".to_string());
GitArchiverError::ApiError {
status: status.as_u16(),
body,
}
}
fn list_repos_for_owner(
client: &Client,
owner: &str,
token: Option<&str>,
) -> Result<Vec<RepoItem>> {
list_repos_with_scope(client, "users", owner, token)
}
fn list_repos_for_org(client: &Client, org: &str, token: Option<&str>) -> Result<Vec<RepoItem>> {
list_repos_with_scope(client, "orgs", org, token)
}
fn list_repos_with_scope(
client: &Client,
scope: &str,
entity: &str,
token: Option<&str>,
) -> Result<Vec<RepoItem>> {
let mut page = 1usize;
let mut repos = Vec::new();
loop {
let url = format!(
"{}/{}/{}/repos?type=public&per_page={}&page={}",
GITHUB_API, scope, entity, DEFAULT_PAGE_SIZE, page
);
let response = with_auth(client.get(&url), token).send()?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Err(GitArchiverError::NotFound(entity.to_string()));
}
if !status.is_success() {
return Err(parse_api_error(response));
}
let batch: Vec<RepoResponse> = response.json()?;
let count = batch.len();
repos.extend(batch.into_iter().filter_map(|repo| {
if repo.private {
return None;
}
Some(RepoItem {
name: repo.name,
full_name: repo.full_name,
clone_url: repo.clone_url,
})
}));
if count < DEFAULT_PAGE_SIZE {
break;
}
page = page.saturating_add(1);
}
Ok(repos)
}
fn fetch_single_repo(
client: &Client,
owner: &str,
repo: &str,
token: Option<&str>,
) -> Result<RepoItem> {
let url = format!("{}/repos/{}/{}", GITHUB_API, owner, repo);
let response = with_auth(client.get(&url), token).send()?;
if response.status() == StatusCode::NOT_FOUND {
return Err(GitArchiverError::NotFound(format!("{owner}/{repo}")));
}
if !response.status().is_success() {
return Err(parse_api_error(response));
}
let data: RepoResponse = response.json()?;
if data.private {
return Err(GitArchiverError::PrivateRepo(data.full_name));
}
Ok(RepoItem {
name: data.name,
full_name: data.full_name,
clone_url: data.clone_url,
})
}
fn clone_repository(repo: &RepoItem, output: &Path, args: &Cli) -> Result<CloneStatus> {
match prepare_clone(repo, output, args)? {
CloneAction::Skipped => Ok(CloneStatus::Skipped),
CloneAction::Running(mut child) => {
let status = child.wait()?;
if status.success() {
return Ok(CloneStatus::Cloned);
}
let code = status
.code()
.map_or_else(|| "signal/unknown".to_string(), |code| code.to_string());
Err(GitArchiverError::CloneFailed {
repo: repo.full_name.clone(),
message: format!("exit code {code}"),
})
}
}
}
fn clone_repository_with_progress(
repo: &RepoItem,
output: &Path,
args: &Cli,
stdout: &mut io::Stdout,
spinner: &Loader,
position: usize,
total: usize,
) -> Result<CloneStatus> {
let mut child = match prepare_clone(repo, output, args)? {
CloneAction::Skipped => {
write!(stdout, "\r\x1b[2K")?;
println!("[skip] {} ({}/{}) exists", repo.full_name, position, total);
return Ok(CloneStatus::Skipped);
}
CloneAction::Running(child) => child,
};
let mut tick = 0usize;
loop {
if let Some(status) = child.try_wait()? {
if status.success() {
write!(stdout, "\r\x1b[2K")?;
println!("[done] {} ({}/{})", repo.full_name, position, total);
return Ok(CloneStatus::Cloned);
}
let code = status
.code()
.map_or_else(|| "signal/unknown".to_string(), |code| code.to_string());
return Err(GitArchiverError::CloneFailed {
repo: repo.full_name.clone(),
message: format!("exit code {code}"),
});
}
let progress = LoaderProgress::from_counts(position as u64, total as u64);
let frame = spinner.line(tick, progress);
let status = format!("[{}/{}] cloning {}", position, total, repo.full_name);
write!(stdout, "\r\x1b[2K{} {}", frame, status)?;
stdout.flush()?;
tick = tick.saturating_add(1);
thread::sleep(Duration::from_millis(120));
}
}
fn prepare_clone(repo: &RepoItem, output: &Path, args: &Cli) -> Result<CloneAction> {
let destination = output.join(&repo.name);
if destination.exists() {
if args.skip_existing {
return Ok(CloneAction::Skipped);
}
if destination.is_dir() {
remove_dir_all(&destination)?;
} else {
remove_file(&destination)?;
}
}
let child = spawn_git_clone(repo, output, args)?;
Ok(CloneAction::Running(child))
}
fn spawn_git_clone(repo: &RepoItem, output: &Path, args: &Cli) -> Result<Child> {
let mut command_args = vec!["clone".to_string()];
if let Some(depth) = args.depth {
command_args.push("--depth".to_string());
command_args.push(depth.to_string());
}
command_args.push(repo.clone_url.clone());
command_args.push(repo.name.clone());
match Command::new("git")
.current_dir(output)
.args(&command_args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(child) => Ok(child),
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(GitArchiverError::MissingGit),
Err(error) => Err(error.into()),
}
}