This commit is contained in:
dualfroz committed 2026-06-10 12:41:14 +02:00
commit 4cf726709f
36 files changed
+8617

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
pub mod ui;
use crate::auth::load_accounts;
use crate::cli::AdoptArgs;
use crate::config::AppConfig;
use crate::providers::{default_account, make_provider};
use anyhow::Result;
use git2::{IndexAddOption, Repository, Signature};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct AdoptResult {
pub name: String,
pub success: bool,
pub detail: String,
pub url: Option<String>,
}
impl AdoptResult {
fn ok(name: &str, detail: impl Into<String>, url: impl Into<String>) -> Self {
Self { name: name.into(), success: true, detail: detail.into(), url: Some(url.into()) }
}
fn err(name: &str, detail: impl Into<String>) -> Self {
Self { name: name.into(), success: false, detail: detail.into(), url: None }
}
}
/// Scan for adoptable folders using the two-tier model:
///
/// Tier 1 — direct children of `root` that are NOT in `groups` and have no `.git`.
/// Tier 2 — direct children of each `root/<group>` folder that have no `.git`.
///
/// Never recurses further. Hidden dirs and the group folders themselves are excluded.
pub fn scan_non_git(root: &Path, groups: &[String]) -> Vec<PathBuf> {
let mut out = Vec::new();
let group_set: std::collections::HashSet<&str> = groups.iter().map(|s| s.as_str()).collect();
// Tier 1: direct children of workspace_root
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() { continue; }
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.starts_with('.') { continue; }
if group_set.contains(name.as_ref()) { continue; } // group container, not a project
if path.join(".git").exists() { continue; }
out.push(path);
}
}
// Tier 2: direct children of each group folder
for group in groups {
let group_dir = root.join(group);
if !group_dir.is_dir() { continue; }
if let Ok(entries) = std::fs::read_dir(&group_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() { continue; }
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.starts_with('.') { continue; }
if path.join(".git").exists() { continue; }
out.push(path);
}
}
}
out.sort();
out
}
/// Core adopt operation for a single folder:
/// git init → stage all → initial commit → create remote repo → add remote → push
pub async fn adopt_folder(
path: &Path,
cfg: &AppConfig,
private: bool,
commit_msg: &str,
provider_hint: Option<&str>,
) -> AdoptResult {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
// ── git init ─────────────────────────────────────────────────────────────
let repo = match Repository::init(path) {
Ok(r) => r,
Err(e) => return AdoptResult::err(&name, format!("git init: {}", e)),
};
// ── stage everything ─────────────────────────────────────────────────────
let mut index = match repo.index() {
Ok(i) => i,
Err(e) => return AdoptResult::err(&name, format!("index: {}", e)),
};
if let Err(e) = index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None) {
return AdoptResult::err(&name, format!("git add: {}", e));
}
if let Err(e) = index.write() {
return AdoptResult::err(&name, format!("index write: {}", e));
}
// ── initial commit ────────────────────────────────────────────────────────
let tree_id = match index.write_tree() {
Ok(id) => id,
Err(e) => return AdoptResult::err(&name, format!("write tree: {}", e)),
};
let tree = match repo.find_tree(tree_id) {
Ok(t) => t,
Err(e) => return AdoptResult::err(&name, format!("find tree: {}", e)),
};
let sig = match Signature::now(&cfg.username, &cfg.email) {
Ok(s) => s,
Err(e) => return AdoptResult::err(&name, format!("signature: {}", e)),
};
if let Err(e) = repo.commit(Some("HEAD"), &sig, &sig, commit_msg, &tree, &[]) {
return AdoptResult::err(&name, format!("commit: {}", e));
}
// ── create remote repo via API ────────────────────────────────────────────
let accounts = load_accounts(cfg).unwrap_or_default();
let acc = match default_account(&accounts, provider_hint.or(Some(&cfg.default_provider))) {
Some(a) => a,
None => return AdoptResult::err(&name, "no provider account configured — run: fxg auth add"),
};
let provider = match make_provider(acc) {
Ok(p) => p,
Err(e) => return AdoptResult::err(&name, format!("provider: {}", e)),
};
let repo_info = match provider.create_repo(&name, private, "").await {
Ok(r) => r,
Err(e) => return AdoptResult::err(&name, format!("create remote repo: {}", e)),
};
// ── git remote add origin ─────────────────────────────────────────────────
// Prefer SSH URL; fall back to HTTPS clone URL
let remote_url = if !repo_info.ssh_url.is_empty() {
repo_info.ssh_url.clone()
} else {
repo_info.url.clone()
};
if let Err(e) = repo.remote("origin", &remote_url) {
return AdoptResult::err(&name, format!("remote add: {}", e));
}
// ── push ──────────────────────────────────────────────────────────────────
let token = crate::auth::get_token(&acc.id()).unwrap_or_default();
let mut cb = git2::RemoteCallbacks::new();
cb.credentials(move |_url, user, allowed| {
if allowed.contains(git2::CredentialType::SSH_KEY) {
git2::Cred::ssh_key_from_agent(user.unwrap_or("git"))
} else {
git2::Cred::userpass_plaintext(user.unwrap_or("git"), &token)
}
});
let mut push_opts = git2::PushOptions::new();
push_opts.remote_callbacks(cb);
let mut remote = match repo.find_remote("origin") {
Ok(r) => r,
Err(e) => return AdoptResult::err(&name, format!("find remote: {}", e)),
};
let branch = repo.head()
.ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()))
.unwrap_or_else(|| cfg.default_branch.clone());
let refspec = format!("refs/heads/{0}:refs/heads/{0}", branch);
if let Err(e) = remote.push(&[&refspec], Some(&mut push_opts)) {
return AdoptResult::err(&name, format!("push: {}", e));
}
AdoptResult::ok(&name, format!("pushed {}", branch), repo_info.url)
}
// ── entry point ───────────────────────────────────────────────────────────────
pub async fn run(args: AdoptArgs, cfg: &AppConfig) -> Result<()> {
let private = args.visibility != "public";
let provider_hint = args.provider.as_deref();
// Single named folder
if let Some(name) = &args.name {
let path = cfg.workspace_root.join(name);
if !path.is_dir() {
anyhow::bail!("folder '{}' not found in workspace_root", name);
}
if path.join(".git").exists() {
anyhow::bail!("'{}' already has a git repo", name);
}
let result = adopt_folder(&path, cfg, private, &args.message, provider_hint).await;
print_result(&result);
return Ok(());
}
let candidates = scan_non_git(&cfg.workspace_root, &cfg.workspace_groups);
if candidates.is_empty() {
println!("Wszystkie foldery w workspace_root już mają .git. Nic do adoptowania.");
return Ok(());
}
if args.all {
// Batch — no TUI
println!("Adoptuję {} folderów...\n", candidates.len());
for path in &candidates {
let result = adopt_folder(path, cfg, private, &args.message, provider_hint).await;
print_result(&result);
}
} else {
// Interactive TUI
ui::run_adopt_tui(candidates, &cfg.workspace_root, cfg, private, &args.message, provider_hint).await?;
}
Ok(())
}
fn print_result(r: &AdoptResult) {
if r.success {
let url = r.url.as_deref().unwrap_or("");
println!(" ✓ {} → {}", r.name, url);
} else {
println!(" ✗ {} — {}", r.name, r.detail);
}
}
+192
View File
@@ -0,0 +1,192 @@
use crate::adopt::{adopt_folder, AdoptResult};
use crate::config::AppConfig;
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
use std::path::{Path, PathBuf};
#[derive(PartialEq)]
enum Mode { Select, Progress }
struct State {
folders: Vec<PathBuf>,
workspace_root: PathBuf,
selected: Vec<bool>,
cursor: usize,
mode: Mode,
results: Vec<AdoptResult>,
quit: bool,
}
impl State {
fn new(folders: Vec<PathBuf>, workspace_root: PathBuf) -> Self {
let len = folders.len();
Self {
folders, workspace_root,
selected: vec![false; len],
cursor: 0, mode: Mode::Select,
results: vec![], quit: false,
}
}
fn toggle(&mut self) {
if let Some(v) = self.selected.get_mut(self.cursor) { *v = !*v; }
}
fn selected_paths(&self) -> Vec<PathBuf> {
self.folders.iter().enumerate()
.filter(|(i, _)| self.selected[*i])
.map(|(_, p)| p.clone())
.collect()
}
}
/// Show path relative to workspace_root, e.g. "clients/client-a"
fn rel_name(p: &Path, root: &Path) -> String {
p.strip_prefix(root)
.map(|r| r.to_string_lossy().to_string())
.unwrap_or_else(|_| p.file_name().unwrap_or_default().to_string_lossy().to_string())
}
fn render_select(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let selected_count = state.selected.iter().filter(|&&v| v).count();
let title = format!(
" Foldery bez .git ({} zaznaczonych — Enter aby adoptować) ",
selected_count
);
let block = Block::default().title(title).borders(Borders::ALL);
let items: Vec<ListItem> = state.folders.iter().enumerate().map(|(i, p)| {
let check = if state.selected[i] { "[x]" } else { "[ ]" };
let style = if i == state.cursor {
Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let label = rel_name(p, &state.workspace_root);
ListItem::new(format!(" {} {}", check, label)).style(style)
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_progress(f: &mut Frame, results: &[AdoptResult], total: usize, area: ratatui::layout::Rect) {
let done = results.len();
let title = format!(" Adoptowanie ({}/{}) ", done, total);
let block = Block::default().title(title).borders(Borders::ALL);
let items: Vec<ListItem> = results.iter().map(|r| {
if r.success {
let url = r.url.as_deref().unwrap_or("");
ListItem::new(format!(" ✓ {} → {}", r.name, url))
.style(Style::default().fg(Color::Green))
} else {
ListItem::new(format!(" ✗ {} — {}", r.name, r.detail))
.style(Style::default().fg(Color::Red))
}
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render(f: &mut Frame, state: &State, total_to_adopt: usize) {
let area = f.area();
let outer = Block::default().title(" fxg — Adopt ").borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
match state.mode {
Mode::Select => render_select(f, state, chunks[0]),
Mode::Progress => render_progress(f, &state.results, total_to_adopt, chunks[0]),
}
let hint = match state.mode {
Mode::Select => " [Space] zaznacz [a] wszystkie [Enter] adoptuj zaznaczone [q] wyjdź",
Mode::Progress => " [q] wyjdź",
};
f.render_widget(
Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
}
fn handle_key(state: &mut State, key: crossterm::event::KeyEvent) {
match state.mode {
Mode::Select => match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Char('a') => state.selected.iter_mut().for_each(|v| *v = true),
KeyCode::Char(' ') => state.toggle(),
KeyCode::Up | KeyCode::Char('k') => { if state.cursor > 0 { state.cursor -= 1; } }
KeyCode::Down | KeyCode::Char('j') => {
if state.cursor + 1 < state.folders.len() { state.cursor += 1; }
}
KeyCode::Enter => {
if state.selected.iter().any(|&v| v) {
state.mode = Mode::Progress;
}
}
_ => {}
},
Mode::Progress => {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
state.quit = true;
}
}
}
}
pub async fn run_adopt_tui(
folders: Vec<PathBuf>,
workspace_root: &Path,
cfg: &AppConfig,
private: bool,
commit_msg: &str,
provider_hint: Option<&str>,
) -> Result<()> {
let mut state = State::new(folders, workspace_root.to_path_buf());
let mut pending: Option<Vec<PathBuf>> = None;
let mut total_to_adopt = 0usize;
// We need to leave TUI to run async adopt (git2 + HTTP) then re-enter for results.
// Strategy: collect selection in TUI, exit TUI, run adopts, show summary in plain text.
// This avoids threading git2/reqwest into the ratatui event loop.
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state, total_to_adopt))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
handle_key(&mut state, key);
// When user confirms (mode flipped to Progress), capture selection and break
if state.mode == Mode::Progress && pending.is_none() {
pending = Some(state.selected_paths());
total_to_adopt = pending.as_ref().unwrap().len();
state.quit = true; // exit TUI loop, run adopts outside
}
}
Ok(state.quit)
})?;
let paths = match pending {
Some(p) if !p.is_empty() => p,
_ => return Ok(()),
};
// Run adopts outside TUI (async + network)
println!("\nAdoptuję {} projekt(ów)...\n", paths.len());
for path in &paths {
let label = rel_name(path, cfg.workspace_root.as_path());
let result = adopt_folder(path, cfg, private, commit_msg, provider_hint).await;
if result.success {
println!(" ✓ {} → {}", label, result.url.as_deref().unwrap_or(""));
} else {
println!(" ✗ {} — {}", label, result.detail);
}
}
println!();
Ok(())
}
+51
View File
@@ -0,0 +1,51 @@
use crate::cli::{Cli, Commands};
use anyhow::Result;
pub async fn run(cli: Cli) -> Result<()> {
match cli.command {
Commands::Config => {
let cfg = crate::config::load_config()?;
crate::config::run(cfg).await
}
Commands::Auth(args) => {
let cfg = crate::config::load_config()?;
crate::auth::run(args, &cfg).await
}
Commands::Init(args) => {
let cfg = crate::config::load_config()?;
crate::init::run(args, &cfg).await
}
Commands::Status(args) => {
let cfg = crate::config::load_config()?;
crate::status::run(args, &cfg).await
}
Commands::Sync(args) => {
let cfg = crate::config::load_config()?;
crate::sync::run(args, &cfg).await
}
Commands::Release(args) => {
let cfg = crate::config::load_config()?;
crate::release::run(args, &cfg).await
}
Commands::Refactor(args) => {
let cfg = crate::config::load_config()?;
crate::refactor::run(args, &cfg).await
}
Commands::Stats(args) => {
let cfg = crate::config::load_config()?;
crate::stats::run(args, &cfg).await
}
Commands::Workspace(args) => {
let cfg = crate::config::load_config()?;
crate::workspace::run(args, &cfg).await
}
Commands::Summary(args) => {
let cfg = crate::config::load_config()?;
crate::summary::run(args, &cfg).await
}
Commands::Adopt(args) => {
let cfg = crate::config::load_config()?;
crate::adopt::run(args, &cfg).await
}
}
}
+146
View File
@@ -0,0 +1,146 @@
pub mod ui;
use crate::cli::{AuthAction, AuthArgs};
use crate::config::AppConfig;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub provider: String,
pub alias: String,
pub username: String,
pub email: String,
pub base_url: Option<String>,
pub is_default: bool,
}
impl Account {
pub fn id(&self) -> String {
format!("{}/{}", self.provider, self.alias)
}
}
// ── persistence ───────────────────────────────────────────────────────────────
#[derive(Serialize, Deserialize, Default)]
struct AccountsFile {
accounts: Vec<Account>,
}
pub fn accounts_path(cfg: &AppConfig) -> PathBuf {
cfg.data_dir.join("accounts.toml")
}
pub fn load_accounts(cfg: &AppConfig) -> Result<Vec<Account>> {
let path = accounts_path(cfg);
if path.exists() {
let raw = std::fs::read_to_string(&path)?;
let file: AccountsFile = toml::from_str(&raw)?;
Ok(file.accounts)
} else {
Ok(vec![])
}
}
pub fn save_accounts(accounts: &[Account], cfg: &AppConfig) -> Result<()> {
let path = accounts_path(cfg);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let file = AccountsFile { accounts: accounts.to_vec() };
std::fs::write(path, toml::to_string_pretty(&file)?)?;
Ok(())
}
// ── keyring ───────────────────────────────────────────────────────────────────
const SERVICE: &str = "fxg";
pub fn store_token(id: &str, token: &str) -> Result<()> {
keyring::Entry::new(SERVICE, id)?.set_password(token)?;
Ok(())
}
pub fn get_token(id: &str) -> Result<String> {
Ok(keyring::Entry::new(SERVICE, id)?.get_password()?)
}
pub fn delete_token(id: &str) -> Result<()> {
keyring::Entry::new(SERVICE, id)?.delete_credential()?;
Ok(())
}
// ── mutation helpers ──────────────────────────────────────────────────────────
fn add_account(
accounts: &mut Vec<Account>,
account: Account,
token: &str,
cfg: &AppConfig,
) -> Result<()> {
let id = account.id();
accounts.retain(|a| a.id() != id);
accounts.push(account);
save_accounts(accounts, cfg)?;
store_token(&id, token)?;
Ok(())
}
fn remove_account(accounts: &mut Vec<Account>, id: &str, cfg: &AppConfig) -> Result<()> {
accounts.retain(|a| a.id() != id);
save_accounts(accounts, cfg)?;
let _ = delete_token(id);
Ok(())
}
fn switch_default(accounts: &mut Vec<Account>, id: &str, cfg: &AppConfig) -> Result<()> {
let exists = accounts.iter().any(|a| a.id() == id);
anyhow::ensure!(exists, "account '{}' not found", id);
for a in accounts.iter_mut() {
a.is_default = a.id() == id;
}
save_accounts(accounts, cfg)?;
Ok(())
}
// ── entry point ───────────────────────────────────────────────────────────────
pub async fn run(args: AuthArgs, cfg: &AppConfig) -> Result<()> {
let mut accounts = load_accounts(cfg)?;
match args.action {
None => {
let updated = ui::run_auth_tui(accounts, cfg)?;
save_accounts(&updated, cfg)?;
}
Some(AuthAction::Add { provider, token, alias, url }) => {
let provider = provider.unwrap_or_else(|| cfg.default_provider.clone());
let alias = alias.unwrap_or_else(|| "personal".into());
let token = token.ok_or_else(|| anyhow::anyhow!("--token is required"))?;
let account = Account {
provider, alias, username: cfg.username.clone(),
email: cfg.email.clone(), base_url: url, is_default: accounts.is_empty(),
};
add_account(&mut accounts, account, &token, cfg)?;
println!("✓ Account added");
}
Some(AuthAction::Remove { account }) => {
remove_account(&mut accounts, &account, cfg)?;
println!("✓ Account removed");
}
Some(AuthAction::Switch { account }) => {
switch_default(&mut accounts, &account, cfg)?;
println!("✓ Default switched to {}", account);
}
Some(AuthAction::Test { account }) => {
let id = account.unwrap_or_else(|| {
accounts.iter().find(|a| a.is_default).map(|a| a.id()).unwrap_or_default()
});
let token = get_token(&id)?;
println!("Token for {}: {}…", id, &token[..token.len().min(8)]);
}
}
Ok(())
}
+118
View File
@@ -0,0 +1,118 @@
use crate::auth::Account;
use crate::config::AppConfig;
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Cell, Paragraph, Row, Table},
Frame,
};
struct State {
accounts: Vec<Account>,
selected: usize,
status: Option<String>,
quit: bool,
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default()
.title(" fxg — Authenticated Accounts ")
.borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(2)])
.split(inner);
let header = Row::new(vec!["", "Provider", "Alias", "Email", "Status"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = state.accounts.iter().enumerate().map(|(i, a)| {
let star = if a.is_default { "★" } else { " " };
let status_cell = Cell::from("✓ active").style(Style::default().fg(Color::Green));
let style = if i == state.selected {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
};
Row::new(vec![
Cell::from(format!("{} [{}]", star, i + 1)),
Cell::from(a.provider.clone()),
Cell::from(a.alias.clone()),
Cell::from(a.email.clone()),
status_cell,
]).style(style)
}).collect();
let widths = [
Constraint::Length(6),
Constraint::Length(10),
Constraint::Length(12),
Constraint::Fill(1),
Constraint::Length(10),
];
let table = Table::new(rows, widths).header(header);
f.render_widget(table, chunks[0]);
let hint = state.status.as_deref().unwrap_or(
" [a] add [r] remove [s] set default [t] test [q] quit"
);
f.render_widget(
Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
}
pub fn run_auth_tui(mut accounts: Vec<Account>, _cfg: &AppConfig) -> Result<Vec<Account>> {
let mut state = State { accounts: accounts.clone(), selected: 0, status: None, quit: false };
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
state.status = None;
match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Up | KeyCode::Char('k') => {
if state.selected > 0 { state.selected -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if state.selected + 1 < state.accounts.len() { state.selected += 1; }
}
KeyCode::Char('a') => {
// must restore terminal temporarily for inquire
// handled outside the raw-mode loop
state.status = Some(" Press [q] first, then use: fxg auth add --provider <p> --token <t>".into());
}
KeyCode::Char('r') => {
if let Some(a) = state.accounts.get(state.selected) {
let id = a.id();
state.accounts.retain(|x| x.id() != id);
let _ = crate::auth::delete_token(&id);
if state.selected > 0 { state.selected -= 1; }
state.status = Some(format!(" ✓ Removed {}", id));
}
}
KeyCode::Char('s') => {
if let Some(a) = state.accounts.get(state.selected) {
let id = a.id();
for acc in state.accounts.iter_mut() {
acc.is_default = acc.id() == id;
}
state.status = Some(format!(" ✓ Default set to {}", id));
}
}
_ => {}
}
}
Ok(state.quit)
})?;
accounts = state.accounts;
Ok(accounts)
}
+229
View File
@@ -0,0 +1,229 @@
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[command(name = "fxg", about = "Multi-repo git workflow manager", version)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Configure fxg settings
Config,
/// Manage provider accounts
Auth(AuthArgs),
/// Initialize new repositories
Init(InitArgs),
/// View repository status
Status(StatusArgs),
/// Sync repositories (pull / commit / push / branch / PR)
Sync(SyncArgs),
/// Manage releases, tags and changelogs
Release(ReleaseArgs),
/// Rename, find and clean code
Refactor(RefactorArgs),
/// Code statistics and metrics
Stats(StatsArgs),
/// Manage repo workspaces
Workspace(WorkspaceArgs),
/// Summarize repository structure, languages, git history and dependencies
Summary(SummaryArgs),
/// Import existing local folders into git + create remote repo
Adopt(AdoptArgs),
}
#[derive(Args)]
pub struct AuthArgs {
#[command(subcommand)]
pub action: Option<AuthAction>,
}
#[derive(Subcommand)]
pub enum AuthAction {
Add {
#[arg(long)] provider: Option<String>,
#[arg(long)] token: Option<String>,
#[arg(long)] alias: Option<String>,
#[arg(long)] url: Option<String>,
},
Remove { account: String },
Switch { account: String },
Test { account: Option<String> },
}
#[derive(Args)]
pub struct InitArgs {
pub name: Option<String>,
#[arg(long)] pub provider: Option<String>,
#[arg(long, value_delimiter = ',')] pub tags: Vec<String>,
#[arg(long, default_value = "private")] pub status: String,
#[arg(long, default_value = "main")] pub branch: String,
#[arg(long)] pub batch: Option<String>,
}
#[derive(Args)]
pub struct StatusArgs {
pub repo: Option<String>,
#[arg(long)] pub workspace: Option<String>,
#[arg(long)] pub filter: Option<String>,
}
#[derive(Args)]
pub struct SyncArgs {
#[command(subcommand)]
pub action: Option<SyncAction>,
}
#[derive(Subcommand)]
pub enum SyncAction {
Pull {
#[arg(long)] all: bool,
#[arg(long)] workspace: Option<String>,
},
Commit {
#[arg(short = 'm', long)] message: Option<String>,
#[arg(long)] all: bool,
},
Push {
#[arg(long)] all: bool,
#[arg(long)] force_with_lease: bool,
/// If set, also stage+commit dirty repos with this message before pushing
#[arg(short = 'm', long)] message: Option<String>,
},
Branch(BranchArgs),
Pr(PrArgs),
}
#[derive(Args)]
pub struct PrArgs {
#[command(subcommand)]
pub action: PrAction,
}
#[derive(Subcommand)]
pub enum PrAction {
Create {
#[arg(long)] title: Option<String>,
#[arg(long)] base: Option<String>,
#[arg(long)] head: Option<String>,
#[arg(long, value_delimiter = ',')] reviewers: Vec<String>,
#[arg(long, value_delimiter = ',')] labels: Vec<String>,
#[arg(long)] repo: Option<String>,
},
}
#[derive(Args)]
pub struct BranchArgs {
#[command(subcommand)]
pub action: BranchAction,
}
#[derive(Subcommand)]
pub enum BranchAction {
Create { name: String, #[arg(long, value_delimiter = ',')] repos: Vec<String> },
Delete { name: String },
Switch { name: String, #[arg(long)] all: bool },
}
#[derive(Args)]
pub struct ReleaseArgs {
#[command(subcommand)]
pub action: Option<ReleaseAction>,
}
#[derive(Subcommand)]
pub enum ReleaseAction {
Create {
version: Option<String>,
#[arg(long, default_value = "patch")] release_type: String,
#[arg(long)] generate_changelog: bool,
},
List,
Tag { version: String, #[arg(long, value_delimiter = ',')] repos: Vec<String> },
}
#[derive(Args)]
pub struct RefactorArgs {
#[command(subcommand)]
pub action: Option<RefactorAction>,
}
#[derive(Subcommand)]
pub enum RefactorAction {
Rename {
kind: String, from: String, to: String,
#[arg(long, value_delimiter = ',')] repos: Vec<String>,
#[arg(long)] dry_run: bool,
},
Clean { target: String },
Find {
pattern: String,
#[arg(long)] repos: Option<String>,
#[arg(long = "type")] find_type: Option<String>,
},
Generate { target: String },
}
#[derive(Args)]
pub struct StatsArgs {
#[command(subcommand)]
pub action: Option<StatsAction>,
#[arg(long)] pub repo: Option<String>,
#[arg(long)] pub workspace: Option<String>,
/// Save output to <repo>/.fxg/stats.md
#[arg(long)] pub save: bool,
}
#[derive(Subcommand)]
pub enum StatsAction {
Time {
#[arg(long)] repo: Option<String>,
#[arg(long)] since: Option<String>,
},
Lines, Languages, Functions, Security,
}
#[derive(Args)]
pub struct WorkspaceArgs {
#[command(subcommand)]
pub action: Option<WorkspaceAction>,
}
#[derive(Subcommand)]
pub enum WorkspaceAction {
Create { name: String },
Add { workspace: String, repos: Vec<String> },
Remove { name: String },
List,
}
#[derive(Args)]
pub struct SummaryArgs {
/// Repository name (defaults to current directory)
pub repo: Option<String>,
#[arg(long)] pub workspace: Option<String>,
/// Print to stdout instead of saving to <repo>/.fxg/summary.md
#[arg(long)] pub print: bool,
}
#[derive(Args)]
pub struct AdoptArgs {
/// Folder name inside workspace_root to adopt (omit for interactive TUI)
pub name: Option<String>,
/// Adopt all non-git folders without prompting
#[arg(long)] pub all: bool,
/// Provider to use (default: from config)
#[arg(long)] pub provider: Option<String>,
/// Visibility: private (default) or public
#[arg(long, default_value = "private")] pub visibility: String,
/// Initial commit message
#[arg(long, short = 'm', default_value = "chore: init")] pub message: String,
}
+141
View File
@@ -0,0 +1,141 @@
pub mod ui;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub workspace_root: PathBuf,
pub data_dir: PathBuf,
pub default_provider: String,
pub default_branch: String,
pub default_visibility: String,
pub editor: String,
pub username: String,
pub email: String,
pub gitignore_template: String,
pub readme_template: String,
pub share_stats: bool,
pub timezone: String,
pub vsc_data_dir: PathBuf,
/// Folder names inside workspace_root treated as organisational groups.
/// Their direct children are scanned as repos, not the group folder itself.
/// Example: ["clients", "internal", "opensource"]
#[serde(default)]
pub workspace_groups: Vec<String>,
}
impl Default for AppConfig {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
Self {
workspace_root: home.join("projects"),
data_dir: home.join(".fxg"),
default_provider: "github".into(),
default_branch: "main".into(),
default_visibility: "private".into(),
editor: std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()),
username: String::new(),
email: String::new(),
gitignore_template: "node".into(),
readme_template: "heartbyte-default".into(),
share_stats: false,
timezone: "Europe/Warsaw".into(),
vsc_data_dir: home.join(".fxg/vsc-data"),
workspace_groups: vec![],
}
}
}
impl AppConfig {
pub fn fields(&self) -> Vec<(&'static str, String)> {
vec![
("workspace_root", self.workspace_root.to_string_lossy().into()),
("data_dir", self.data_dir.to_string_lossy().into()),
("default_provider", self.default_provider.clone()),
("default_branch", self.default_branch.clone()),
("default_visibility", self.default_visibility.clone()),
("editor", self.editor.clone()),
("username", self.username.clone()),
("email", self.email.clone()),
("gitignore_template", self.gitignore_template.clone()),
("readme_template", self.readme_template.clone()),
("share_stats", self.share_stats.to_string()),
("timezone", self.timezone.clone()),
("vsc_data_dir", self.vsc_data_dir.to_string_lossy().into()),
("workspace_groups", self.workspace_groups.join(", ")),
]
}
pub fn set_field(&mut self, key: &str, value: String) -> Result<()> {
match key {
"workspace_root" => self.workspace_root = PathBuf::from(&value),
"data_dir" => self.data_dir = PathBuf::from(&value),
"default_provider" => self.default_provider = value,
"default_branch" => self.default_branch = value,
"default_visibility" => self.default_visibility = value,
"editor" => self.editor = value,
"username" => self.username = value,
"email" => self.email = value,
"gitignore_template" => self.gitignore_template = value,
"readme_template" => self.readme_template = value,
"share_stats" => self.share_stats = value == "true",
"timezone" => self.timezone = value,
"vsc_data_dir" => self.vsc_data_dir = PathBuf::from(&value),
"workspace_groups" => {
self.workspace_groups = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
other => anyhow::bail!("unknown config key: {}", other),
}
Ok(())
}
}
pub fn config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".fxg")
.join("config.toml")
}
pub fn load_config() -> Result<AppConfig> {
let path = config_path();
if path.exists() {
let raw = std::fs::read_to_string(&path)?;
Ok(toml::from_str(&raw)?)
} else {
Ok(AppConfig::default())
}
}
pub fn save_config(cfg: &AppConfig) -> Result<()> {
let path = config_path();
ensure_parent(&path)?;
std::fs::write(path, toml::to_string_pretty(cfg)?)?;
Ok(())
}
#[allow(dead_code)]
pub fn ensure_data_dir(cfg: &AppConfig) -> Result<()> {
std::fs::create_dir_all(&cfg.data_dir)?;
std::fs::create_dir_all(&cfg.vsc_data_dir)?;
Ok(())
}
fn ensure_parent(p: &Path) -> Result<()> {
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir)?;
}
Ok(())
}
pub async fn run(cfg: AppConfig) -> Result<()> {
let updated = ui::run_config_tui(cfg)?;
save_config(&updated)?;
Ok(())
}
+137
View File
@@ -0,0 +1,137 @@
use crate::config::AppConfig;
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyEvent};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
struct State {
fields: Vec<(&'static str, String)>,
selected: usize,
editing: bool,
edit_buf: String,
quit: bool,
}
impl State {
fn new(cfg: &AppConfig) -> Self {
Self {
fields: cfg.fields(),
selected: 0,
editing: false,
edit_buf: String::new(),
quit: false,
}
}
fn to_config(&self, base: AppConfig) -> AppConfig {
let mut cfg = base;
let labels: Vec<&str> = self.fields.iter().map(|(k, _)| *k).collect();
for (i, (_, val)) in self.fields.iter().enumerate() {
let _ = cfg.set_field(labels[i], val.clone());
}
cfg
}
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default()
.title(" fxg — Configuration ")
.borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
let items: Vec<ListItem> = state.fields.iter().enumerate().map(|(i, (key, val))| {
let active = i == state.selected;
let value_str = if active && state.editing {
format!("{}_", state.edit_buf)
} else {
val.clone()
};
let line = Line::from(vec![
Span::raw(format!(" [{:2}] ", i + 1)),
Span::styled(format!("{:<22}", key), Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::raw(value_str),
]);
let style = if active {
Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(line).style(style)
}).collect();
f.render_widget(List::new(items), chunks[0]);
let hint = if state.editing {
" [Enter] confirm [Esc] cancel [Backspace] delete"
} else {
" [↑/↓] navigate [Enter] edit [r] reset [q] save & quit"
};
f.render_widget(
Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
}
fn handle_key(state: &mut State, key: KeyEvent) {
if state.editing {
match key.code {
KeyCode::Enter => {
state.fields[state.selected].1 = state.edit_buf.clone();
state.editing = false;
state.edit_buf.clear();
}
KeyCode::Esc => {
state.editing = false;
state.edit_buf.clear();
}
KeyCode::Char(c) => state.edit_buf.push(c),
KeyCode::Backspace => { state.edit_buf.pop(); }
_ => {}
}
return;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Char('r') => {
let defaults = AppConfig::default().fields();
state.fields = defaults;
}
KeyCode::Up | KeyCode::Char('k') => {
if state.selected > 0 { state.selected -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if state.selected + 1 < state.fields.len() { state.selected += 1; }
}
KeyCode::Enter => {
state.edit_buf = state.fields[state.selected].1.clone();
state.editing = true;
}
_ => {}
}
}
pub fn run_config_tui(cfg: AppConfig) -> Result<AppConfig> {
let mut state = State::new(&cfg);
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
handle_key(&mut state, key);
}
Ok(state.quit)
})?;
Ok(state.to_config(cfg))
}
+203
View File
@@ -0,0 +1,203 @@
pub mod wizard;
use crate::auth::load_accounts;
use crate::cli::InitArgs;
use crate::config::AppConfig;
use crate::providers::make_provider;
use anyhow::Result;
use git2::{IndexAddOption, Repository, Signature};
use std::path::{Path, PathBuf};
use std::time::Instant;
pub struct InitConfig {
pub name: String,
pub account_id: String,
pub private: bool,
pub tags: Vec<String>,
#[allow(dead_code)]
pub branch: String,
pub extra_branches: Vec<String>,
pub readme: bool,
pub gitignore: bool,
pub changelog: bool,
pub env_example: bool,
pub license: bool,
pub contributing: bool,
pub template: String,
pub collaborators: Vec<(String, String)>,
}
// ── git helpers ───────────────────────────────────────────────────────────────
pub fn git_init(path: &Path) -> Result<Repository> { Ok(Repository::init(path)?) }
pub fn write_files(path: &Path, ic: &InitConfig) -> Result<()> {
if ic.readme { std::fs::write(path.join("README.md"), format!("# {}\n", ic.name))?; }
if ic.gitignore { std::fs::write(path.join(".gitignore"), gitignore_content(&ic.template))?; }
if ic.changelog { std::fs::write(path.join("CHANGELOG.md"), "# Changelog\n\nAll notable changes...\n")?; }
if ic.env_example { std::fs::write(path.join(".env.example"), "# Environment variables\n")?; }
if ic.license {
std::fs::write(path.join("LICENSE"), license_mit())?;
}
if ic.contributing {
std::fs::write(path.join("CONTRIBUTING.md"), contributing_md())?;
}
Ok(())
}
fn gitignore_content(t: &str) -> &'static str {
match t { "rust" => "/target\nCargo.lock\n", "python" => "__pycache__/\n*.pyc\n.venv/\n", _ => "/node_modules\ndist/\n.env\n" }
}
fn license_mit() -> &'static str {
"MIT License\n\nCopyright (c) [year] [author]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
}
fn contributing_md() -> &'static str {
"# Contributing\n\nThank you for considering contributing!\n\n## How to contribute\n\n1. Fork the repository\n2. Create a feature branch: `git checkout -b feat/my-feature`\n3. Commit your changes: `git commit -m 'feat: add my feature'`\n4. Push to the branch: `git push origin feat/my-feature`\n5. Open a pull request\n\n## Code style\n\n- Follow existing conventions\n- Write tests for new functionality\n- Update documentation as needed\n"
}
pub fn initial_commit(repo: &Repository, username: &str, email: &str) -> Result<()> {
let mut idx = repo.index()?;
idx.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
idx.write()?;
let tree = repo.find_tree(idx.write_tree()?)?;
let sig = Signature::now(username, email)?;
repo.commit(Some("HEAD"), &sig, &sig, "chore: init", &tree, &[])?;
Ok(())
}
pub fn push_to_remote(repo: &Repository, token: &str, refspec: &str) -> Result<()> {
let mut remote = repo.find_remote("origin")?;
let t = token.to_string();
let mut cb = git2::RemoteCallbacks::new();
cb.credentials(move |_url, user, allowed| {
if allowed.contains(git2::CredentialType::SSH_KEY) { git2::Cred::ssh_key_from_agent(user.unwrap_or("git")) }
else { git2::Cred::userpass_plaintext(user.unwrap_or("git"), &t) }
});
let mut opts = git2::PushOptions::new();
opts.remote_callbacks(cb);
remote.push(&[refspec], Some(&mut opts))?;
Ok(())
}
// ── orchestration ─────────────────────────────────────────────────────────────
pub async fn execute(ic: &InitConfig, cfg: &AppConfig) -> Result<()> {
let accounts = load_accounts(cfg)?;
let account = accounts.iter().find(|a| a.id() == ic.account_id)
.ok_or_else(|| anyhow::anyhow!("account '{}' not found — run: fxg auth add", ic.account_id))?;
let token = crate::auth::get_token(&account.id())?;
let provider = make_provider(account)?;
let t0 = Instant::now();
let info = provider.create_repo(&ic.name, ic.private, "").await?;
println!(" ✓ Created remote repo {} ({})", ic.name, ic.account_id);
if !ic.tags.is_empty() {
if let Err(e) = provider.set_topics(&ic.name, &ic.tags).await {
println!(" ⚠ set_topics: {}", e);
} else {
println!(" ✓ Applied tags: {}", ic.tags.join(", "));
}
}
let repo_path: PathBuf = cfg.workspace_root.join(&ic.name);
std::fs::create_dir_all(&repo_path)?;
println!(" ✓ mkdir {}", repo_path.display());
let repo = git_init(&repo_path)?;
println!(" ✓ git init");
write_files(&repo_path, ic)?;
println!(" ✓ Created initial files");
if let Err(e) = initial_commit(&repo, &cfg.username, &cfg.email) {
println!(" ✗ initial commit: {}", e);
} else {
println!(" ✓ Initial commit: \"chore: init\"");
}
repo.remote("origin", &info.ssh_url)?;
println!(" ✓ git remote add origin {}", info.ssh_url);
if let Err(e) = push_to_remote(&repo, &token, "refs/heads/main:refs/heads/main") {
println!(" ✗ git push: {}", e);
} else {
println!(" ✓ git push -u origin main");
}
for branch in &ic.extra_branches {
let head = repo.head()?.peel_to_commit()?;
let _ = repo.branch(branch, &head, false);
println!(" ✓ Created branch: {}", branch);
}
for (user, role) in &ic.collaborators {
if let Err(e) = provider.add_collaborator(&ic.name, user, role).await {
println!(" ✗ collaborator {}: {}", user, e);
} else {
println!(" ✓ Added collaborator: {} ({})", user, role);
}
}
println!("\n Done in {:.1}s · {}", t0.elapsed().as_secs_f64(), info.url);
Ok(())
}
// ── entry point ───────────────────────────────────────────────────────────────
fn build_noninteractive(args: &InitArgs, accounts: &[crate::auth::Account]) -> Option<InitConfig> {
let name = args.name.clone()?;
let account = crate::providers::default_account(accounts, args.provider.as_deref())?;
Some(InitConfig {
name, account_id: account.id(),
private: args.status != "public",
tags: args.tags.clone(),
branch: args.branch.clone(),
extra_branches: vec![],
readme: true, gitignore: true, changelog: true, env_example: true,
license: false, contributing: false,
template: "blank".into(), collaborators: vec![],
})
}
pub async fn run(args: InitArgs, cfg: &AppConfig) -> Result<()> {
let accounts = load_accounts(cfg)?;
// --batch mode
if let Some(batch_input) = &args.batch {
let names: Vec<String> = if std::path::Path::new(batch_input).exists() {
std::fs::read_to_string(batch_input)?
.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect()
} else {
batch_input.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
};
let account = crate::providers::default_account(&accounts, args.provider.as_deref())
.ok_or_else(|| anyhow::anyhow!("no account configured"))?;
for name in names {
let ic = InitConfig {
name: name.clone(), account_id: account.id(),
private: args.status != "public", tags: args.tags.clone(),
branch: args.branch.clone(), extra_branches: vec![],
readme: true, gitignore: true, changelog: false, env_example: false,
license: false, contributing: false,
template: "blank".into(), collaborators: vec![],
};
println!("\n══ {} ══", name);
if let Err(e) = execute(&ic, cfg).await { println!(" ✗ {}", e); }
}
return Ok(());
}
// flags-only mode: name + provider both given → skip wizard
if args.name.is_some() && args.provider.is_some() {
let ic = build_noninteractive(&args, &accounts)
.ok_or_else(|| anyhow::anyhow!("no matching account for provider '{}'", args.provider.as_deref().unwrap_or("")))?;
return execute(&ic, cfg).await;
}
// interactive wizard
let ic = wizard::run_wizard(args.name.as_deref(), &accounts)?;
execute(&ic, cfg).await
}
+134
View File
@@ -0,0 +1,134 @@
use crate::auth::Account;
use crate::init::InitConfig;
use anyhow::Result;
use inquire::{Confirm, MultiSelect, Select, Text};
pub fn run_wizard(prefill_name: Option<&str>, accounts: &[Account]) -> Result<InitConfig> {
println!("\n══════════════════════════════════════════");
println!(" fxg — New Repository");
println!("══════════════════════════════════════════\n");
// Step 1 — Name
let name = {
let mut t = Text::new("Step 1/7 · Repository name:");
if let Some(n) = prefill_name {
t = t.with_default(n);
}
t.with_validator(|v: &str| {
use inquire::validator::Validation;
if v.is_empty() {
Ok(Validation::Invalid("Name cannot be empty".into()))
} else if v.contains(' ') {
Ok(Validation::Invalid("Name cannot contain spaces".into()))
} else {
Ok(Validation::Valid)
}
}).prompt()?
};
// Step 2 — Provider & Account
let account_id = if accounts.is_empty() {
anyhow::bail!("No accounts configured. Run: fxg auth add");
} else {
let choices: Vec<String> = accounts.iter().map(|a| a.id()).collect();
let default_idx = accounts.iter().position(|a| a.is_default).unwrap_or(0);
Select::new("Step 2/7 · Provider & Account:", choices.clone())
.with_starting_cursor(default_idx)
.prompt()?
};
// Step 3 — Visibility
let visibility = Select::new(
"Step 3/7 · Visibility:",
vec!["private", "public", "internal"],
).prompt()?;
// Step 4 — Tags
let tags_raw = Text::new("Step 4/7 · Tags (comma-separated):")
.with_default("")
.prompt()?;
let tags: Vec<String> = tags_raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// Step 5 — Files
let file_options = vec![
"README.md", ".gitignore", "CHANGELOG.md", ".env.example", "LICENSE", "CONTRIBUTING.md",
];
let selected_files = MultiSelect::new("Step 5/7 · Initial files:", file_options.clone())
.with_default(&[0, 1, 2, 3])
.prompt()?;
let template = Select::new(
" Template:",
vec!["node", "rust", "python", "blank"],
).prompt()?;
// Step 6 — Branches
let extra_raw = Text::new("Step 6/7 · Extra branches (comma-separated):")
.with_default("dev")
.prompt()?;
let extra_branches: Vec<String> = extra_raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// Step 7 — Collaborators
let add_collabs = Confirm::new("Step 7/7 · Add collaborators?")
.with_default(false)
.prompt()?;
let collaborators = if add_collabs {
collect_collaborators()?
} else {
vec![]
};
println!("\n──────────────────────────────────────────");
let confirmed = Confirm::new("Proceed with creation?")
.with_default(true)
.prompt()?;
if !confirmed {
anyhow::bail!("Cancelled by user");
}
Ok(InitConfig {
name,
account_id,
private: visibility == "private" || visibility == "internal",
tags,
branch: "main".into(),
extra_branches,
readme: selected_files.contains(&"README.md"),
gitignore: selected_files.contains(&".gitignore"),
changelog: selected_files.contains(&"CHANGELOG.md"),
env_example: selected_files.contains(&".env.example"),
license: selected_files.contains(&"LICENSE"),
contributing: selected_files.contains(&"CONTRIBUTING.md"),
template: template.to_string(),
collaborators,
})
}
fn collect_collaborators() -> Result<Vec<(String, String)>> {
let mut result = Vec::new();
loop {
let username = Text::new(" Collaborator username (blank to finish):")
.prompt()?;
if username.is_empty() { break; }
let role = Select::new(" Role:", vec!["read", "write", "admin"])
.with_starting_cursor(1)
.prompt()?;
result.push((username, role.to_string()));
let more = Confirm::new(" Add another?").with_default(false).prompt()?;
if !more { break; }
}
Ok(result)
}
+28
View File
@@ -0,0 +1,28 @@
mod adopt;
mod app;
mod auth;
mod cli;
mod config;
mod init;
mod providers;
mod refactor;
mod release;
mod stats;
mod summary;
mod status;
mod sync;
mod tui;
mod workspace;
use anyhow::Result;
use clap::Parser;
#[tokio::main]
async fn main() -> Result<()> {
let cli = cli::Cli::parse();
if let Err(e) = app::run(cli).await {
eprintln!("error: {:#}", e);
std::process::exit(1);
}
Ok(())
}
+191
View File
@@ -0,0 +1,191 @@
use super::{PrInfo, Provider, RepoInfo, RepoMeta};
use anyhow::{Context, Result};
use async_trait::async_trait;
use reqwest::{header, Client, Response};
use serde_json::{json, Value};
/// Gitea / Forgejo REST API v1 provider.
pub struct GiteaProvider {
client: Client,
owner: String,
base_url: String, // e.g. "https://gitea.example.com/api/v1"
}
impl GiteaProvider {
pub fn new(base_url: &str, owner: &str, token: &str) -> Result<Self> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(&format!("token {}", token))?,
);
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("fxg/0.1"));
let client = Client::builder().default_headers(headers).build()?;
Ok(Self {
client,
owner: owner.to_string(),
base_url: base_url.trim_end_matches('/').to_string(),
})
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
}
async fn check(resp: Response) -> Result<Value> {
let status = resp.status();
let text = resp.text().await?;
if status.is_success() {
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
} else {
anyhow::bail!("Gitea API {} — {}", status, text)
}
}
}
#[async_trait]
impl Provider for GiteaProvider {
async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result<RepoInfo> {
let body = json!({ "name": name, "private": private, "description": description, "auto_init": false });
let resp = self.client.post(self.url("user/repos")).json(&body).send().await?;
let v = Self::check(resp).await.context("create_repo")?;
Ok(RepoInfo {
name: v["name"].as_str().unwrap_or(name).to_string(),
url: v["html_url"].as_str().unwrap_or("").to_string(),
ssh_url: v["ssh_url"].as_str().unwrap_or("").to_string(),
})
}
async fn delete_repo(&self, name: &str) -> Result<()> {
let resp = self.client
.delete(self.url(&format!("repos/{}/{}", self.owner, name)))
.send().await?;
Self::check(resp).await.context("delete_repo")?;
Ok(())
}
async fn set_topics(&self, repo: &str, topics: &[String]) -> Result<()> {
let body = json!({ "topics": topics });
let resp = self.client
.put(self.url(&format!("repos/{}/{}/topics", self.owner, repo)))
.json(&body).send().await?;
Self::check(resp).await.context("set_topics")?;
Ok(())
}
async fn create_branch(&self, repo: &str, branch: &str, from: &str) -> Result<()> {
let body = json!({ "new_branch_name": branch, "old_branch_name": from });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/branches", self.owner, repo)))
.json(&body).send().await?;
Self::check(resp).await.context("create_branch")?;
Ok(())
}
async fn create_pull_request(
&self, repo: &str, title: &str, body: &str, head: &str, base: &str,
) -> Result<PrInfo> {
let payload = json!({ "title": title, "body": body, "head": head, "base": base });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_pull_request")?;
Ok(PrInfo {
number: v["number"].as_u64().unwrap_or(0),
url: v["html_url"].as_str().unwrap_or("").to_string(),
})
}
async fn request_reviewers(
&self, repo: &str, pr_number: u64, reviewers: &[String],
) -> Result<()> {
let body = json!({ "reviewers": reviewers });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls/{}/requested_reviewers", self.owner, repo, pr_number)))
.json(&body).send().await?;
Self::check(resp).await.context("request_reviewers")?;
Ok(())
}
async fn add_labels(
&self, repo: &str, issue_number: u64, labels: &[String],
) -> Result<()> {
// Fetch existing labels to find IDs; create any that are missing
let list_resp = self.client
.get(self.url(&format!("repos/{}/{}/labels", self.owner, repo)))
.send().await?;
let existing: Vec<(u64, String)> = if list_resp.status().is_success() {
list_resp.json::<serde_json::Value>().await
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|l| l["id"].as_u64().zip(l["name"].as_str().map(|s| s.to_string())))
.collect()
} else {
vec![]
};
let mut ids: Vec<u64> = Vec::new();
for label in labels {
if let Some((id, _)) = existing.iter().find(|(_, n)| n == label) {
ids.push(*id);
} else {
let body = json!({ "name": label, "color": "#0075ca" });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/labels", self.owner, repo)))
.json(&body).send().await?;
if let Ok(v) = Self::check(resp).await {
if let Some(id) = v["id"].as_u64() { ids.push(id); }
}
}
}
if !ids.is_empty() {
let body = json!({ "labels": ids });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/issues/{}/labels", self.owner, repo, issue_number)))
.json(&body).send().await?;
Self::check(resp).await.context("add_labels")?;
}
Ok(())
}
async fn add_collaborator(&self, repo: &str, user: &str, role: &str) -> Result<()> {
let permission = match role { "read" => "read", "admin" => "admin", _ => "write" };
let body = json!({ "permission": permission });
let resp = self.client
.put(self.url(&format!("repos/{}/{}/collaborators/{}", self.owner, repo, user)))
.json(&body).send().await?;
Self::check(resp).await.context("add_collaborator")?;
Ok(())
}
async fn create_release(
&self, repo: &str, tag: &str, title: &str, body: &str, draft: bool,
) -> Result<String> {
let payload = json!({ "tag_name": tag, "name": title, "body": body, "draft": draft });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/releases", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_release")?;
Ok(v["html_url"].as_str().unwrap_or("").to_string())
}
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta> {
let resp = self.client
.get(self.url(&format!("repos/{}/{}", self.owner, repo)))
.send().await?;
let v = Self::check(resp).await.context("repo_meta")?;
Ok(RepoMeta {
open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32,
open_prs: v["open_pr_counter"].as_u64().unwrap_or(0) as u32,
stars: v["stars_count"].as_u64().unwrap_or(0) as u32,
})
}
async fn whoami(&self) -> Result<String> {
let resp = self.client.get(self.url("user")).send().await?;
let v = Self::check(resp).await.context("whoami")?;
Ok(v["login"].as_str().unwrap_or("unknown").to_string())
}
}
+182
View File
@@ -0,0 +1,182 @@
use super::{PrInfo, Provider, RepoInfo, RepoMeta};
use anyhow::{Context, Result};
use async_trait::async_trait;
use reqwest::{header, Client, Response};
use serde_json::{json, Value};
pub struct GitHubProvider {
client: Client,
owner: String,
base_url: String,
}
impl GitHubProvider {
pub fn new(base_url: &str, owner: &str, token: &str) -> Result<Self> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(&format!("Bearer {}", token))?,
);
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/vnd.github+json"),
);
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static("fxg/0.1"),
);
headers.insert(
"X-GitHub-Api-Version",
header::HeaderValue::from_static("2022-11-28"),
);
let client = Client::builder().default_headers(headers).build()?;
Ok(Self { client, owner: owner.to_string(), base_url: base_url.trim_end_matches('/').to_string() })
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
}
async fn check(resp: Response) -> Result<Value> {
let status = resp.status();
let text = resp.text().await?;
if status.is_success() {
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
} else {
anyhow::bail!("GitHub API {} — {}", status, text)
}
}
}
#[async_trait]
impl Provider for GitHubProvider {
async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result<RepoInfo> {
let body = json!({ "name": name, "private": private, "description": description });
let resp = self.client.post(self.url("user/repos")).json(&body).send().await?;
let v = Self::check(resp).await.context("create_repo")?;
Ok(RepoInfo {
name: v["name"].as_str().unwrap_or(name).to_string(),
url: v["html_url"].as_str().unwrap_or("").to_string(),
ssh_url: v["ssh_url"].as_str().unwrap_or("").to_string(),
})
}
async fn delete_repo(&self, name: &str) -> Result<()> {
let resp = self.client
.delete(self.url(&format!("repos/{}/{}", self.owner, name)))
.send().await?;
Self::check(resp).await.context("delete_repo")?;
Ok(())
}
async fn set_topics(&self, repo: &str, topics: &[String]) -> Result<()> {
let body = json!({ "names": topics });
let resp = self.client
.put(self.url(&format!("repos/{}/{}/topics", self.owner, repo)))
.json(&body).send().await?;
Self::check(resp).await.context("set_topics")?;
Ok(())
}
async fn create_branch(&self, repo: &str, branch: &str, from: &str) -> Result<()> {
// Resolve 'from' to a SHA first
let ref_url = self.url(&format!("repos/{}/{}/git/ref/heads/{}", self.owner, repo, from));
let resp = self.client.get(&ref_url).send().await?;
let v = Self::check(resp).await.context("resolve ref")?;
let sha = v["object"]["sha"].as_str()
.ok_or_else(|| anyhow::anyhow!("could not resolve SHA for {}", from))?
.to_string();
let body = json!({ "ref": format!("refs/heads/{}", branch), "sha": sha });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/git/refs", self.owner, repo)))
.json(&body).send().await?;
Self::check(resp).await.context("create_branch")?;
Ok(())
}
async fn create_pull_request(
&self, repo: &str, title: &str, body: &str, head: &str, base: &str,
) -> Result<PrInfo> {
let payload = json!({ "title": title, "body": body, "head": head, "base": base });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_pull_request")?;
Ok(PrInfo {
number: v["number"].as_u64().unwrap_or(0),
url: v["html_url"].as_str().unwrap_or("").to_string(),
})
}
async fn add_collaborator(&self, repo: &str, user: &str, role: &str) -> Result<()> {
let body = json!({ "permission": role });
let resp = self.client
.put(self.url(&format!("repos/{}/{}/collaborators/{}", self.owner, repo, user)))
.json(&body).send().await?;
Self::check(resp).await.context("add_collaborator")?;
Ok(())
}
async fn create_release(
&self, repo: &str, tag: &str, title: &str, body: &str, draft: bool,
) -> Result<String> {
let payload = json!({ "tag_name": tag, "name": title, "body": body, "draft": draft });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/releases", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_release")?;
Ok(v["html_url"].as_str().unwrap_or("").to_string())
}
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta> {
let resp = self.client
.get(self.url(&format!("repos/{}/{}", self.owner, repo)))
.send().await?;
let v = Self::check(resp).await.context("repo_meta")?;
let pr_resp = self.client
.get(self.url(&format!("repos/{}/{}/pulls?state=open&per_page=100", self.owner, repo)))
.send().await?;
let open_prs = if pr_resp.status().is_success() {
let pr_list: serde_json::Value = pr_resp.json().await.unwrap_or(serde_json::Value::Array(vec![]));
pr_list.as_array().map(|a| a.len() as u32).unwrap_or(0)
} else {
0
};
Ok(RepoMeta {
open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32,
open_prs,
stars: v["stargazers_count"].as_u64().unwrap_or(0) as u32,
})
}
async fn request_reviewers(
&self, repo: &str, pr_number: u64, reviewers: &[String],
) -> Result<()> {
let body = json!({ "reviewers": reviewers });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls/{}/requested_reviewers", self.owner, repo, pr_number)))
.json(&body).send().await?;
Self::check(resp).await.context("request_reviewers")?;
Ok(())
}
async fn add_labels(
&self, repo: &str, issue_number: u64, labels: &[String],
) -> Result<()> {
let body = json!({ "labels": labels });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/issues/{}/labels", self.owner, repo, issue_number)))
.json(&body).send().await?;
Self::check(resp).await.context("add_labels")?;
Ok(())
}
async fn whoami(&self) -> Result<String> {
let resp = self.client.get(self.url("user")).send().await?;
let v = Self::check(resp).await.context("whoami")?;
Ok(v["login"].as_str().unwrap_or("unknown").to_string())
}
}
+193
View File
@@ -0,0 +1,193 @@
use super::{PrInfo, Provider, RepoInfo, RepoMeta};
use anyhow::{Context, Result};
use async_trait::async_trait;
use reqwest::{header, Client, Response};
use serde_json::{json, Value};
pub struct GitLabProvider {
client: Client,
namespace: String,
base_url: String,
}
impl GitLabProvider {
pub fn new(base_url: &str, namespace: &str, token: &str) -> Result<Self> {
let mut headers = header::HeaderMap::new();
headers.insert(
"PRIVATE-TOKEN",
header::HeaderValue::from_str(token)?,
);
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static("fxg/0.1"),
);
let client = Client::builder().default_headers(headers).build()?;
Ok(Self {
client,
namespace: namespace.to_string(),
base_url: base_url.trim_end_matches('/').to_string(),
})
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
}
fn repo_id(&self, repo: &str) -> String {
// Percent-encode "namespace/repo" for use in URL path segments
let raw = format!("{}/{}", self.namespace, repo);
raw.replace('/', "%2F")
}
async fn check(resp: Response) -> Result<Value> {
let status = resp.status();
let text = resp.text().await?;
if status.is_success() {
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
} else {
anyhow::bail!("GitLab API {} — {}", status, text)
}
}
}
#[async_trait]
impl Provider for GitLabProvider {
async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result<RepoInfo> {
let visibility = if private { "private" } else { "public" };
let body = json!({
"name": name,
"visibility": visibility,
"description": description,
"namespace_path": self.namespace,
});
let resp = self.client.post(self.url("projects")).json(&body).send().await?;
let v = Self::check(resp).await.context("create_repo")?;
Ok(RepoInfo {
name: v["name"].as_str().unwrap_or(name).to_string(),
url: v["web_url"].as_str().unwrap_or("").to_string(),
ssh_url: v["ssh_url_to_repo"].as_str().unwrap_or("").to_string(),
})
}
async fn delete_repo(&self, name: &str) -> Result<()> {
let resp = self.client
.delete(self.url(&format!("projects/{}", self.repo_id(name))))
.send().await?;
Self::check(resp).await.context("delete_repo")?;
Ok(())
}
async fn set_topics(&self, repo: &str, topics: &[String]) -> Result<()> {
let body = json!({ "topics": topics });
let resp = self.client
.put(self.url(&format!("projects/{}", self.repo_id(repo))))
.json(&body).send().await?;
Self::check(resp).await.context("set_topics")?;
Ok(())
}
async fn create_branch(&self, repo: &str, branch: &str, from: &str) -> Result<()> {
let body = json!({ "branch": branch, "ref": from });
let resp = self.client
.post(self.url(&format!("projects/{}/repository/branches", self.repo_id(repo))))
.json(&body).send().await?;
Self::check(resp).await.context("create_branch")?;
Ok(())
}
async fn create_pull_request(
&self, repo: &str, title: &str, body: &str, head: &str, base: &str,
) -> Result<PrInfo> {
let payload = json!({
"title": title, "description": body,
"source_branch": head, "target_branch": base,
});
let resp = self.client
.post(self.url(&format!("projects/{}/merge_requests", self.repo_id(repo))))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_pull_request")?;
Ok(PrInfo {
number: v["iid"].as_u64().unwrap_or(0),
url: v["web_url"].as_str().unwrap_or("").to_string(),
})
}
async fn request_reviewers(
&self, repo: &str, pr_number: u64, reviewers: &[String],
) -> Result<()> {
// Resolve usernames to GitLab user IDs
let mut reviewer_ids: Vec<u64> = Vec::new();
for username in reviewers {
let resp = self.client
.get(self.url(&format!("users?username={}", username)))
.send().await?;
if let Ok(v) = Self::check(resp).await {
if let Some(id) = v.as_array()
.and_then(|a| a.first())
.and_then(|u| u["id"].as_u64())
{
reviewer_ids.push(id);
}
}
}
if !reviewer_ids.is_empty() {
let body = json!({ "reviewer_ids": reviewer_ids });
let resp = self.client
.put(self.url(&format!("projects/{}/merge_requests/{}", self.repo_id(repo), pr_number)))
.json(&body).send().await?;
Self::check(resp).await.context("request_reviewers")?;
}
Ok(())
}
async fn add_labels(
&self, repo: &str, pr_number: u64, labels: &[String],
) -> Result<()> {
let body = json!({ "add_labels": labels.join(",") });
let resp = self.client
.put(self.url(&format!("projects/{}/merge_requests/{}", self.repo_id(repo), pr_number)))
.json(&body).send().await?;
Self::check(resp).await.context("add_labels")?;
Ok(())
}
async fn add_collaborator(&self, repo: &str, user: &str, role: &str) -> Result<()> {
// GitLab access levels: 30=developer, 40=maintainer, 50=owner
let level: u8 = match role { "read" => 20, "write" => 30, _ => 40 };
let body = json!({ "user_id": user, "access_level": level });
let resp = self.client
.post(self.url(&format!("projects/{}/members", self.repo_id(repo))))
.json(&body).send().await?;
Self::check(resp).await.context("add_collaborator")?;
Ok(())
}
async fn create_release(
&self, repo: &str, tag: &str, title: &str, body: &str, _draft: bool,
) -> Result<String> {
let payload = json!({ "tag_name": tag, "name": title, "description": body });
let resp = self.client
.post(self.url(&format!("projects/{}/releases", self.repo_id(repo))))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_release")?;
Ok(v["_links"]["self"].as_str().unwrap_or("").to_string())
}
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta> {
let resp = self.client
.get(self.url(&format!("projects/{}", self.repo_id(repo))))
.send().await?;
let v = Self::check(resp).await.context("repo_meta")?;
Ok(RepoMeta {
open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32,
open_prs: v["merge_requests_count"].as_u64().unwrap_or(0) as u32,
stars: v["star_count"].as_u64().unwrap_or(0) as u32,
})
}
async fn whoami(&self) -> Result<String> {
let resp = self.client.get(self.url("user")).send().await?;
let v = Self::check(resp).await.context("whoami")?;
Ok(v["username"].as_str().unwrap_or("unknown").to_string())
}
}
+123
View File
@@ -0,0 +1,123 @@
mod gitea;
mod github;
mod gitlab;
pub use gitea::GiteaProvider;
pub use github::GitHubProvider;
pub use gitlab::GitLabProvider;
use crate::auth::Account;
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoInfo {
pub name: String,
pub url: String,
pub ssh_url: String,
}
#[derive(Debug, Clone)]
pub struct PrInfo {
pub number: u64,
pub url: String,
}
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub struct RepoMeta {
pub open_issues: u32,
pub open_prs: u32,
pub stars: u32,
}
#[async_trait]
#[allow(dead_code)]
pub trait Provider: Send + Sync {
/// Create a new remote repository.
async fn create_repo(
&self, name: &str, private: bool, description: &str,
) -> Result<RepoInfo>;
/// Delete a remote repository.
async fn delete_repo(&self, name: &str) -> Result<()>;
/// Replace the topic list on a repository.
async fn set_topics(&self, repo: &str, topics: &[String]) -> Result<()>;
/// Create a branch from an existing ref.
async fn create_branch(&self, repo: &str, branch: &str, from: &str) -> Result<()>;
/// Open a pull-request / merge-request.
async fn create_pull_request(
&self, repo: &str, title: &str, body: &str, head: &str, base: &str,
) -> Result<PrInfo>;
/// Request reviewers for an open pull request / merge request.
async fn request_reviewers(
&self, _repo: &str, _pr_number: u64, _reviewers: &[String],
) -> Result<()> {
Ok(())
}
/// Add labels to an open pull request / issue.
async fn add_labels(
&self, _repo: &str, _issue_number: u64, _labels: &[String],
) -> Result<()> {
Ok(())
}
/// Add a collaborator to a repository.
async fn add_collaborator(&self, repo: &str, user: &str, role: &str) -> Result<()>;
/// Create a release tag with release notes.
async fn create_release(
&self, repo: &str, tag: &str, title: &str, body: &str, draft: bool,
) -> Result<String>;
/// Fetch basic metadata (issues, PRs, stars).
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta>;
/// Verify the token works, return the authenticated username.
async fn whoami(&self) -> Result<String>;
}
/// Build a boxed `Provider` from an `Account`.
/// The token must already be present in the keyring.
pub fn make_provider(account: &Account) -> Result<Box<dyn Provider>> {
let token = crate::auth::get_token(&account.id())?;
match account.provider.as_str() {
"github" => {
let base = account.base_url.as_deref()
.unwrap_or("https://api.github.com");
Ok(Box::new(GitHubProvider::new(base, &account.username, &token)?))
}
"gitlab" => {
let base = account.base_url.as_deref()
.unwrap_or("https://gitlab.com/api/v4");
Ok(Box::new(GitLabProvider::new(base, &account.username, &token)?))
}
"gitea" | "forgejo" => {
let base = account.base_url.as_deref()
.ok_or_else(|| anyhow::anyhow!("gitea/forgejo requires base_url in account config"))?;
let api_base = if base.ends_with("/api/v1") { base.to_string() } else { format!("{}/api/v1", base.trim_end_matches('/')) };
Ok(Box::new(GiteaProvider::new(&api_base, &account.username, &token)?))
}
other => anyhow::bail!("unsupported provider: {}", other),
}
}
/// Resolve the default account for a given provider name (or the global default).
pub fn default_account<'a>(
accounts: &'a [crate::auth::Account],
provider_hint: Option<&str>,
) -> Option<&'a crate::auth::Account> {
if let Some(p) = provider_hint {
accounts.iter().find(|a| a.provider == p && a.is_default)
.or_else(|| accounts.iter().find(|a| a.provider == p))
} else {
accounts.iter().find(|a| a.is_default)
.or_else(|| accounts.first())
}
}
+185
View File
@@ -0,0 +1,185 @@
pub mod ui;
use crate::cli::{RefactorAction, RefactorArgs};
use crate::config::AppConfig;
use anyhow::Result;
use std::path::Path;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct FindResult {
pub repo: String,
pub file: String,
pub line: usize,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct CleanResult {
pub file: String,
pub kind: String,
pub line: usize,
pub detail: String,
}
// ── find ──────────────────────────────────────────────────────────────────────
pub fn find_in_dir(pattern: &str, root: &Path, repo_name: &str) -> Vec<FindResult> {
let mut results = Vec::new();
let rx = match regex_simple(pattern) {
Some(r) => r,
None => return results,
};
walk_files(root, &mut |file_path, rel| {
if let Ok(content) = std::fs::read_to_string(&file_path) {
for (i, line) in content.lines().enumerate() {
if rx(line) {
results.push(FindResult {
repo: repo_name.to_string(),
file: rel.clone(),
line: i + 1,
text: line.trim().chars().take(120).collect(),
});
}
}
}
});
results
}
// ── rename ────────────────────────────────────────────────────────────────────
pub fn rename_in_dir(from: &str, to: &str, root: &Path, dry_run: bool) -> Vec<FindResult> {
let mut hits = Vec::new();
walk_files(root, &mut |file_path, rel| {
if let Ok(content) = std::fs::read_to_string(&file_path) {
if content.contains(from) {
let count = content.matches(from).count();
hits.push(FindResult {
repo: String::new(),
file: rel.clone(),
line: count,
text: format!("{} occurrence(s)", count),
});
if !dry_run {
let _ = std::fs::write(&file_path, content.replace(from, to));
}
}
}
});
hits
}
// ── clean ─────────────────────────────────────────────────────────────────────
pub fn find_unused(kind: &str, root: &Path) -> Vec<CleanResult> {
let mut results = Vec::new();
let pattern: &dyn Fn(&str) -> bool = match kind {
"unused-imports" => &|l: &str| l.trim_start().starts_with("use ") && l.contains("// unused"),
"unused-vars" => &|l: &str| l.contains("_unused") || l.contains("let _"),
"dead-code" => &|l: &str| l.contains("#[allow(dead_code)]"),
"unused-functions" => &|l: &str| l.contains("fn ") && l.contains("// dead"),
_ => return results,
};
walk_files(root, &mut |file_path, rel| {
if let Ok(content) = std::fs::read_to_string(&file_path) {
for (i, line) in content.lines().enumerate() {
if pattern(line) {
results.push(CleanResult {
file: rel.clone(), kind: kind.to_string(),
line: i + 1, detail: line.trim().chars().take(80).collect(),
});
}
}
}
});
results
}
// ── generate ──────────────────────────────────────────────────────────────────
pub fn generate_todo_list(root: &Path) -> Vec<FindResult> {
find_in_dir("TODO|FIXME|HACK|XXX", root, "")
}
// ── helpers ───────────────────────────────────────────────────────────────────
fn walk_files<F: FnMut(std::path::PathBuf, String)>(root: &Path, cb: &mut F) {
let Ok(entries) = std::fs::read_dir(root) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if !matches!(name.as_ref(), ".git" | "target" | "node_modules" | ".next") {
walk_files(&path, cb);
}
} else if let Some(ext) = path.extension() {
if matches!(ext.to_string_lossy().as_ref(), "rs" | "ts" | "js" | "py" | "go" | "md") {
let rel = path.to_string_lossy().to_string();
cb(path, rel);
}
}
}
}
fn regex_simple(pattern: &str) -> Option<Box<dyn Fn(&str) -> bool>> {
// Simple substring/literal match (not full regex to avoid extra dep)
let p = pattern.to_string();
Some(Box::new(move |line: &str| line.contains(p.as_str())))
}
// ── entry point ───────────────────────────────────────────────────────────────
pub async fn run(args: RefactorArgs, cfg: &AppConfig) -> Result<()> {
let root = &cfg.workspace_root;
match args.action {
None => ui::run_refactor_tui(cfg),
Some(RefactorAction::Find { pattern, repos, .. }) => {
let search_root = repos.as_ref()
.map(|r| root.join(r))
.unwrap_or_else(|| root.clone());
let results = find_in_dir(&pattern, &search_root, "");
if results.is_empty() {
println!("No matches found for '{}'", pattern);
} else {
for r in &results {
println!(" {}:{} — {}", r.file, r.line, r.text);
}
println!("\n {} match(es)", results.len());
}
Ok(())
}
Some(RefactorAction::Rename { from, to, repos, dry_run, kind: _ }) => {
let search_root = repos.first()
.map(|r| root.join(r))
.unwrap_or_else(|| root.clone());
let hits = rename_in_dir(&from, &to, &search_root, dry_run);
let label = if dry_run { "Would rename" } else { "Renamed" };
for h in &hits {
println!(" {} {} occurrence(s) in {}", label, h.line, h.file);
}
println!("\n {} file(s) affected", hits.len());
Ok(())
}
Some(RefactorAction::Clean { target }) => {
let results = find_unused(&target, root);
for r in &results {
println!(" {}:{} — {}", r.file, r.line, r.detail);
}
println!("\n {} item(s) flagged", results.len());
Ok(())
}
Some(RefactorAction::Generate { target }) => {
match target.as_str() {
"todo" => {
for r in generate_todo_list(root) {
println!(" {}:{} — {}", r.file, r.line, r.text);
}
}
_ => println!("Unknown generate target: {}", target),
}
Ok(())
}
}
}
+180
View File
@@ -0,0 +1,180 @@
use crate::config::AppConfig;
use crate::refactor::{CleanResult, FindResult};
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
#[derive(PartialEq)]
enum View { Menu, FindResults, CleanResults }
struct State {
view: View,
find_results: Vec<FindResult>,
clean_results: Vec<CleanResult>,
cursor: usize,
scroll: usize,
input_buf: String,
input_active: bool,
status: Option<String>,
quit: bool,
}
const MENU_ITEMS: &[&str] = &[
"[1] Rename identifier",
"[2] Find pattern",
"[3] Clean unused",
"[4] Generate TODO list",
];
impl State {
fn new() -> Self {
Self {
view: View::Menu, find_results: vec![], clean_results: vec![],
cursor: 0, scroll: 0, input_buf: String::new(), input_active: false,
status: None, quit: false,
}
}
}
fn render_menu(f: &mut Frame, state: &State, area: Rect) {
let block = Block::default().title(" Refactor operations ").borders(Borders::ALL);
let items: Vec<ListItem> = MENU_ITEMS.iter().enumerate().map(|(i, item)| {
let style = if i == state.cursor { Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) }
else { Style::default() };
ListItem::new(format!(" {}", item)).style(style)
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_find(f: &mut Frame, state: &State, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
// Left: file list
let left_block = Block::default().title(" Files ").borders(Borders::ALL);
let items: Vec<ListItem> = state.find_results.iter().enumerate()
.skip(state.scroll)
.take(chunks[0].height.saturating_sub(2) as usize)
.map(|(i, r)| {
let style = if i == state.cursor { Style::default().bg(Color::DarkGray) }
else { Style::default() };
ListItem::new(format!(" {}:{}", r.file.split('/').last().unwrap_or(&r.file), r.line)).style(style)
}).collect();
f.render_widget(List::new(items).block(left_block), chunks[0]);
// Right: context
let right_block = Block::default().title(" Context ").borders(Borders::ALL);
let preview = state.find_results.get(state.cursor)
.map(|r| format!(" {}:{}\n\n {}", r.file, r.line, r.text))
.unwrap_or_else(|| " No selection".into());
f.render_widget(Paragraph::new(preview).block(right_block), chunks[1]);
}
fn render_clean(f: &mut Frame, state: &State, area: Rect) {
let block = Block::default().title(" Clean results ").borders(Borders::ALL);
let items: Vec<ListItem> = state.clean_results.iter().map(|r| {
ListItem::new(format!(" {}:{} [{}] — {}", r.file, r.line, r.kind, r.detail))
.style(Style::default().fg(Color::Yellow))
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default().title(" fxg — Refactor ").borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(2)])
.split(inner);
match state.view {
View::Menu => render_menu(f, state, chunks[0]),
View::FindResults => render_find(f, state, chunks[0]),
View::CleanResults => render_clean(f, state, chunks[0]),
}
let hint_text = if state.input_active {
format!(" Pattern: {}_ ", state.input_buf)
} else {
let base = match state.view {
View::Menu => " [Enter] select [q] quit",
View::FindResults => " [j/k] navigate [Esc] back [q] quit",
View::CleanResults => " [Esc] back [q] quit",
};
if let Some(s) = &state.status { format!(" {} — {}", s, base) } else { base.into() }
};
f.render_widget(Paragraph::new(hint_text).style(Style::default().fg(Color::DarkGray)), chunks[1]);
}
fn handle_key(state: &mut State, key: crossterm::event::KeyEvent, cfg: &AppConfig) {
if state.input_active {
match key.code {
KeyCode::Enter => {
let results = crate::refactor::find_in_dir(&state.input_buf, &cfg.workspace_root, "workspace");
state.find_results = results;
state.cursor = 0;
state.scroll = 0;
state.view = View::FindResults;
state.input_active = false;
}
KeyCode::Esc => { state.input_active = false; state.input_buf.clear(); }
KeyCode::Char(c) => state.input_buf.push(c),
KeyCode::Backspace => { state.input_buf.pop(); }
_ => {}
}
return;
}
match state.view {
View::Menu => match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Up | KeyCode::Char('k') => { if state.cursor > 0 { state.cursor -= 1; } }
KeyCode::Down | KeyCode::Char('j') => { if state.cursor + 1 < MENU_ITEMS.len() { state.cursor += 1; } }
KeyCode::Enter | KeyCode::Char('2') => {
state.input_active = true;
state.input_buf.clear();
state.status = Some("Type pattern and press Enter".into());
}
KeyCode::Char('3') => {
state.clean_results = crate::refactor::find_unused("dead-code", &cfg.workspace_root);
state.view = View::CleanResults;
state.cursor = 0;
}
KeyCode::Char('4') => {
state.find_results = crate::refactor::generate_todo_list(&cfg.workspace_root);
state.view = View::FindResults;
state.cursor = 0;
}
_ => {}
},
View::FindResults | View::CleanResults => match key.code {
KeyCode::Esc | KeyCode::Char('b') => { state.view = View::Menu; state.cursor = 0; }
KeyCode::Char('q') => state.quit = true,
KeyCode::Up | KeyCode::Char('k') => { if state.cursor > 0 { state.cursor -= 1; } }
KeyCode::Down | KeyCode::Char('j') => state.cursor += 1,
_ => {}
},
}
}
pub fn run_refactor_tui(cfg: &AppConfig) -> Result<()> {
let mut state = State::new();
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
handle_key(&mut state, key, cfg);
}
Ok(state.quit)
})
}
+160
View File
@@ -0,0 +1,160 @@
pub mod ui;
use crate::auth::load_accounts;
use crate::cli::{ReleaseAction, ReleaseArgs};
use crate::config::AppConfig;
use crate::providers::make_provider;
use anyhow::Result;
use git2::{Repository, Signature};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ChangelogEntry {
pub kind: String,
pub scope: Option<String>,
pub message: String,
pub sha: String,
}
fn parse_cc(subject: &str) -> Option<(String, Option<String>, String)> {
let (head, tail) = subject.split_once(':')?;
let msg = tail.trim().to_string();
if let Some((kind, scope_raw)) = head.split_once('(') {
Some((kind.to_string(), Some(scope_raw.trim_end_matches(')').to_string()), msg))
} else {
Some((head.to_string(), None, msg))
}
}
pub fn generate_changelog(repo: &Repository, since_tag: Option<&str>) -> Result<Vec<ChangelogEntry>> {
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
let stop_id = since_tag.and_then(|t| repo.revparse_single(t).ok().map(|o| o.id()));
let mut entries = Vec::new();
for id in revwalk {
let id = id?;
if stop_id.map(|s| s == id).unwrap_or(false) { break; }
let commit = repo.find_commit(id)?;
if let Some((kind, scope, message)) = parse_cc(commit.summary().unwrap_or("")) {
entries.push(ChangelogEntry { kind, scope, message, sha: id.to_string()[..7].to_string() });
}
}
Ok(entries)
}
pub fn format_changelog_md(entries: &[ChangelogEntry], version: &str) -> String {
let mut out = format!("## {}\n\n", version);
for (section, header) in &[("feat","### Features"),("fix","### Bug Fixes"),("refactor","### Refactoring"),("chore","### Chores"),("docs","### Documentation")] {
let group: Vec<_> = entries.iter().filter(|e| e.kind == *section).collect();
if group.is_empty() { continue; }
out.push_str(&format!("{}\n\n", header));
for e in group {
let scope_str = e.scope.as_ref().map(|s| format!("**{}**: ", s)).unwrap_or_default();
out.push_str(&format!("- {}{} ({})\n", scope_str, e.message, e.sha));
}
out.push('\n');
}
out
}
fn latest_tag(repo: &Repository) -> Option<String> {
repo.tag_names(None).ok()?.iter().flatten().map(|s| s.to_string()).last()
}
fn bump_version(tag: Option<&str>, release_type: &str) -> String {
let v = tag.unwrap_or("v0.0.0").trim_start_matches('v');
let p: Vec<u64> = v.split('.').filter_map(|x| x.parse().ok()).collect();
let (mj, mn, pt) = (p.first().copied().unwrap_or(0), p.get(1).copied().unwrap_or(0), p.get(2).copied().unwrap_or(0));
match release_type { "major" => format!("v{}.0.0", mj+1), "minor" => format!("v{}.{}.0", mj, mn+1), _ => format!("v{}.{}.{}", mj, mn, pt+1) }
}
fn create_local_tag(repo: &Repository, version: &str, cfg: &AppConfig) -> Result<()> {
let head = repo.head()?.peel_to_commit()?;
let sig = Signature::now(&cfg.username, &cfg.email)?;
repo.tag(version, head.as_object(), &sig, &format!("Release {}", version), false)?;
Ok(())
}
fn push_tag(repo: &Repository, token: &str, version: &str) -> Result<()> {
let t = token.to_string();
let mut remote = repo.find_remote("origin")?;
let mut cb = git2::RemoteCallbacks::new();
cb.credentials(move |_url, user, allowed| {
if allowed.contains(git2::CredentialType::SSH_KEY) { git2::Cred::ssh_key_from_agent(user.unwrap_or("git")) }
else { git2::Cred::userpass_plaintext(user.unwrap_or("git"), &t) }
});
let mut opts = git2::PushOptions::new();
opts.remote_callbacks(cb);
let refspec = format!("refs/tags/{0}:refs/tags/{0}", version);
remote.push(&[&refspec], Some(&mut opts))?;
Ok(())
}
fn tag_one_repo(path: &Path, version: &str, token: &str, cfg: &AppConfig) -> Result<()> {
let repo = Repository::open(path)?;
create_local_tag(&repo, version, cfg)?;
push_tag(&repo, token, version)?;
Ok(())
}
pub async fn run(args: ReleaseArgs, cfg: &AppConfig) -> Result<()> {
let cwd = std::env::current_dir()?;
let repo = Repository::open(&cwd)?;
let accounts = load_accounts(cfg)?;
let account = crate::providers::default_account(&accounts, None)
.ok_or_else(|| anyhow::anyhow!("no account configured — run: fxg auth add"))?;
let token = crate::auth::get_token(&account.id())?;
match args.action {
None => {
let entries = generate_changelog(&repo, latest_tag(&repo).as_deref())?;
if let Some((version, title, body, draft)) = ui::run_release_tui(&repo, entries)? {
println!(" Publishing {}…", version);
create_local_tag(&repo, &version, cfg)?;
push_tag(&repo, &token, &version)?;
println!(" ✓ Tag pushed");
let provider = make_provider(account)?;
let repo_name = cwd.file_name().unwrap_or_default().to_string_lossy();
match provider.create_release(&repo_name, &version, &title, &body, draft).await {
Ok(url) => println!(" ✓ Release created: {}", url),
Err(e) => println!(" ✗ Release API: {}", e),
}
}
}
Some(ReleaseAction::Create { version, release_type, generate_changelog: gc }) => {
let last = latest_tag(&repo);
let ver = version.unwrap_or_else(|| bump_version(last.as_deref(), &release_type));
let body = if gc { let e = generate_changelog(&repo, last.as_deref())?; format_changelog_md(&e, &ver) } else { String::new() };
create_local_tag(&repo, &ver, cfg)?;
println!("✓ Created tag {}", ver);
if let Err(e) = push_tag(&repo, &token, &ver) { println!(" ⚠ push tag: {}", e); } else { println!("✓ Pushed tag"); }
if !body.is_empty() {
let provider = make_provider(account)?;
let repo_name = cwd.file_name().unwrap_or_default().to_string_lossy();
match provider.create_release(&repo_name, &ver, &ver, &body, false).await {
Ok(url) => println!("✓ Release: {}", url),
Err(e) => println!("⚠ Release API: {}", e),
}
}
}
Some(ReleaseAction::List) => {
let names = repo.tag_names(None)?;
for name in names.iter().flatten() { println!(" {}", name); }
}
Some(ReleaseAction::Tag { version, repos }) => {
let targets: Vec<_> = if repos.is_empty() {
vec![cwd.clone()]
} else {
repos.iter().map(|r| cfg.workspace_root.join(r)).collect()
};
for path in targets {
let name = path.file_name().unwrap_or_default().to_string_lossy();
match tag_one_repo(&path, &version, &token, cfg) {
Ok(()) => println!(" ✓ {} tagged {}", name, version),
Err(e) => println!(" ✗ {}: {}", name, e),
}
}
}
}
Ok(())
}
+185
View File
@@ -0,0 +1,185 @@
use crate::release::{format_changelog_md, ChangelogEntry};
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use git2::Repository;
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
#[derive(PartialEq, Clone, Copy)]
enum Step { Version, ReleaseType, ChangelogReview, Confirm }
struct State {
step: Step,
version: String,
rtype: usize, // index into RELEASE_TYPES
changelog: String,
draft: bool,
input_buf: String,
quit: bool,
confirmed: bool,
scroll: u16,
}
const RELEASE_TYPES: &[&str] = &["patch", "minor", "major"];
const STEPS: &[&str] = &["Version", "Type", "Changelog", "Confirm"];
impl State {
fn new(version: String, changelog: String) -> Self {
Self {
step: Step::Version, version: version.clone(), rtype: 0,
changelog, draft: false, input_buf: version,
quit: false, confirmed: false, scroll: 0,
}
}
fn next(&mut self) {
self.step = match self.step {
Step::Version => { self.version = self.input_buf.clone(); Step::ReleaseType }
Step::ReleaseType => Step::ChangelogReview,
Step::ChangelogReview => Step::Confirm,
Step::Confirm => { self.confirmed = true; self.quit = true; return; }
};
}
fn prev(&mut self) {
self.step = match self.step {
Step::ReleaseType => Step::Version,
Step::ChangelogReview => Step::ReleaseType,
Step::Confirm => Step::ChangelogReview,
_ => return,
};
}
}
fn step_bar(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let idx = match state.step { Step::Version => 0, Step::ReleaseType => 1, Step::ChangelogReview => 2, Step::Confirm => 3 };
let text = STEPS.iter().enumerate().map(|(i, s)| {
if i == idx { format!(" [{}] ", s) } else { format!(" {} ", s) }
}).collect::<Vec<_>>().join("→");
f.render_widget(
Paragraph::new(text).style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
area,
);
}
fn render_version(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let block = Block::default().title(" Version tag ").borders(Borders::ALL);
let text = format!(" > {}_ \n\n e.g. v2.1.0", state.input_buf);
f.render_widget(Paragraph::new(text).block(block), area);
}
fn render_rtype(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let block = Block::default().title(" Release type ").borders(Borders::ALL);
let items: Vec<ListItem> = RELEASE_TYPES.iter().enumerate().map(|(i, t)| {
let style = if i == state.rtype { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) }
else { Style::default() };
ListItem::new(format!(" {}", t)).style(style)
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_changelog(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let block = Block::default().title(" Changelog preview (↑/↓ scroll) ").borders(Borders::ALL);
let lines: Vec<Line> = state.changelog.lines().map(|l| Line::from(l.to_string())).collect();
f.render_widget(Paragraph::new(lines).block(block).scroll((state.scroll, 0)), area);
}
fn render_confirm(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let block = Block::default().title(" Confirm release ").borders(Borders::ALL);
let text = format!(
"\n Version : {}\n Type : {}\n Draft : {}\n\n [Enter] publish [d] toggle draft [Esc] cancel",
state.version, RELEASE_TYPES[state.rtype], state.draft
);
f.render_widget(Paragraph::new(text).block(block), area);
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default().title(" fxg — New Release ").borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
step_bar(f, state, chunks[0]);
match state.step {
Step::Version => render_version(f, state, chunks[1]),
Step::ReleaseType => render_rtype(f, state, chunks[1]),
Step::ChangelogReview => render_changelog(f, state, chunks[1]),
Step::Confirm => render_confirm(f, state, chunks[1]),
}
f.render_widget(
Paragraph::new(" [Enter] next [b] back [Esc] cancel")
.style(Style::default().fg(Color::DarkGray)),
chunks[2],
);
}
fn handle_key(state: &mut State, key: crossterm::event::KeyEvent) {
match state.step {
Step::Version => match key.code {
KeyCode::Enter => state.next(),
KeyCode::Esc => state.quit = true,
KeyCode::Char('b') => state.prev(),
KeyCode::Char(c) => state.input_buf.push(c),
KeyCode::Backspace => { state.input_buf.pop(); }
_ => {}
},
Step::ReleaseType => match key.code {
KeyCode::Enter => state.next(),
KeyCode::Esc => state.quit = true,
KeyCode::Char('b') => state.prev(),
KeyCode::Up | KeyCode::Char('k') => { if state.rtype > 0 { state.rtype -= 1; } }
KeyCode::Down | KeyCode::Char('j') => { if state.rtype + 1 < RELEASE_TYPES.len() { state.rtype += 1; } }
_ => {}
},
Step::ChangelogReview => match key.code {
KeyCode::Enter => state.next(),
KeyCode::Esc => state.quit = true,
KeyCode::Char('b') => state.prev(),
KeyCode::Up | KeyCode::Char('k') => { if state.scroll > 0 { state.scroll -= 1; } }
KeyCode::Down | KeyCode::Char('j') => state.scroll += 1,
_ => {}
},
Step::Confirm => match key.code {
KeyCode::Enter => state.next(),
KeyCode::Esc => state.quit = true,
KeyCode::Char('d') => state.draft = !state.draft,
KeyCode::Char('b') => state.prev(),
_ => {}
},
}
}
pub fn run_release_tui(
_repo: &Repository,
entries: Vec<ChangelogEntry>,
) -> Result<Option<(String, String, String, bool)>> {
let version = "v0.1.0".to_string();
let changelog = format_changelog_md(&entries, &version);
let mut state = State::new(version, changelog);
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
handle_key(&mut state, key);
}
Ok(state.quit)
})?;
if state.confirmed {
let body = format_changelog_md(&entries, &state.version);
Ok(Some((state.version.clone(), state.version, body, state.draft)))
} else {
Ok(None)
}
}
+292
View File
@@ -0,0 +1,292 @@
pub mod ui;
use crate::cli::{StatsAction, StatsArgs};
use crate::config::AppConfig;
use anyhow::Result;
use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct CodeStats {
pub total_lines: usize,
pub file_count: usize,
pub function_count: usize,
pub avg_fn_length: f64,
pub languages: Vec<(String, f64)>,
pub todos: Vec<TodoItem>,
pub sec_warnings: Vec<SecWarning>,
pub time_entries: Vec<TimeEntry>,
}
#[derive(Debug, Clone)]
pub struct TodoItem {
pub file: String,
pub line: usize,
pub kind: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct SecWarning {
pub file: String,
pub line: usize,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct TimeEntry {
pub file: String,
pub duration_mins: u64, // stored as minutes
}
// ── VSC time data ─────────────────────────────────────────────────────────────
/// Load time-per-file data from ~/.fxg/vsc-data/<repo_name>.json
/// Expected format: `{ "files": { "src/main.rs": 3600 } }` (seconds)
pub fn load_vsc_data(vsc_dir: &Path, repo_name: &str) -> Vec<TimeEntry> {
let path = vsc_dir.join(format!("{}.json", repo_name));
let Ok(raw) = std::fs::read_to_string(&path) else { return vec![] };
let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw) else { return vec![] };
let Some(files) = val.get("files").and_then(|f| f.as_object()) else { return vec![] };
let mut entries: Vec<TimeEntry> = files.iter()
.filter_map(|(file, secs)| {
let s = secs.as_u64()?;
Some(TimeEntry { file: file.clone(), duration_mins: s / 60 })
}).collect();
entries.sort_by(|a, b| b.duration_mins.cmp(&a.duration_mins));
entries
}
// ── language detection ────────────────────────────────────────────────────────
fn detect_lang(ext: &str) -> Option<&'static str> {
Some(match ext {
"rs" => "Rust",
"ts" | "tsx" => "TypeScript",
"js" | "jsx" => "JavaScript",
"py" => "Python",
"go" => "Go",
"java" => "Java",
"sql" => "SQL",
"sh" => "Shell",
"toml" => "TOML",
"json" => "JSON",
"md" => "Markdown",
_ => return None,
})
}
fn count_fns(content: &str, lang: &str) -> usize {
let pat = match lang { "Rust" => " fn ", "Python" => "def ", "Go" => "func ", _ => "function " };
content.matches(pat).count()
}
// ── secret heuristics ─────────────────────────────────────────────────────────
fn scan_secrets(content: &str, file: &str) -> Vec<SecWarning> {
let mut warnings = Vec::new();
for (i, line) in content.lines().enumerate() {
let l = line.to_lowercase();
if (l.contains("secret") || l.contains("api_key") || l.contains("password") || l.contains("jwt_secret"))
&& line.contains('=') && !line.trim().starts_with("//") && !line.trim().starts_with('#')
&& !line.ends_with("\"\"") && !line.ends_with("''")
{
warnings.push(SecWarning { file: file.to_string(), line: i + 1, reason: "Possible hardcoded secret".into() });
}
}
warnings
}
// ── file walker ───────────────────────────────────────────────────────────────
fn walk_collect(root: &Path, stats: &mut CodeStats) {
let Ok(entries) = std::fs::read_dir(root) else { return };
let mut lang_lines: std::collections::HashMap<String, usize> = Default::default();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if !matches!(name.as_ref(), ".git" | "target" | "node_modules") { walk_collect(&path, stats); }
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let lang = match detect_lang(ext) { Some(l) => l, None => continue };
let Ok(content) = std::fs::read_to_string(&path) else { continue };
let line_count = content.lines().count();
stats.total_lines += line_count;
stats.file_count += 1;
*lang_lines.entry(lang.to_string()).or_default() += line_count;
stats.function_count += count_fns(&content, lang);
let rel = path.to_string_lossy().to_string();
for (i, line) in content.lines().enumerate() {
for kw in &["TODO", "FIXME", "HACK", "XXX"] {
if line.contains(kw) {
stats.todos.push(TodoItem { file: rel.clone(), line: i + 1, kind: kw.to_string(), text: line.trim().chars().take(80).collect() });
break;
}
}
}
stats.sec_warnings.extend(scan_secrets(&content, &rel));
}
let total: usize = lang_lines.values().sum();
stats.languages = lang_lines.iter()
.map(|(l, c)| (l.clone(), if total > 0 { *c as f64 / total as f64 * 100.0 } else { 0.0 }))
.collect();
stats.languages.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
}
pub fn collect_stats(root: &Path, vsc_dir: Option<&Path>) -> Result<CodeStats> {
let mut stats = CodeStats::default();
walk_collect(root, &mut stats);
if stats.function_count > 0 {
stats.avg_fn_length = stats.total_lines as f64 / stats.function_count as f64;
}
if let Some(vsc) = vsc_dir {
let repo_name = root.file_name().unwrap_or_default().to_string_lossy();
stats.time_entries = load_vsc_data(vsc, &repo_name);
}
Ok(stats)
}
pub async fn run(args: StatsArgs, cfg: &AppConfig) -> Result<()> {
let root = args.repo.as_ref().map(|r| cfg.workspace_root.join(r)).unwrap_or_else(|| cfg.workspace_root.clone());
let vsc_dir = cfg.vsc_data_dir.exists().then(|| cfg.vsc_data_dir.as_path());
let stats = collect_stats(&root, vsc_dir)?;
let save = args.save;
let mut output = String::new();
match args.action {
None => {
if save {
let md = stats_to_md(&stats, &root);
save_stats_md(&root, &md)?;
println!(" ✓ Saved to {}/.fxg/stats.md", root.file_name().unwrap_or_default().to_string_lossy());
} else {
return ui::run_stats_tui(stats);
}
}
Some(StatsAction::Security) => {
if stats.sec_warnings.is_empty() {
output.push_str(" ✓ No security warnings found\n");
} else {
for w in &stats.sec_warnings {
output.push_str(&format!(" ⚠ {}:{} — {}\n", w.file, w.line, w.reason));
}
}
}
Some(StatsAction::Lines) => {
output.push_str(&format!(" Lines: {} Files: {}\n", stats.total_lines, stats.file_count));
}
Some(StatsAction::Languages) => {
for (l, p) in &stats.languages {
output.push_str(&format!(" {:15} {:.1}%\n", l, p));
}
}
Some(StatsAction::Functions) => {
output.push_str(&format!(" Functions: {} Avg length: {:.1} lines\n", stats.function_count, stats.avg_fn_length));
}
Some(StatsAction::Time { repo, since }) => {
let r = repo.as_ref().map(|r| cfg.workspace_root.join(r)).unwrap_or_else(|| root.clone());
let name = r.file_name().unwrap_or_default().to_string_lossy();
let mut entries = load_vsc_data(&cfg.vsc_data_dir, &name);
if let Some(since_str) = &since {
use chrono::NaiveDate;
if let Ok(date) = NaiveDate::parse_from_str(since_str, "%Y-%m-%d") {
let since_ts = date.and_hms_opt(0, 0, 0)
.map(|dt| dt.and_utc().timestamp())
.unwrap_or(0);
entries.retain(|e| {
let full_path = r.join(&e.file);
std::fs::metadata(&full_path)
.and_then(|m| m.modified())
.map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64 >= since_ts)
.unwrap_or(true)
})
.unwrap_or(true)
});
}
}
if entries.is_empty() {
output.push_str(&format!(" No VSC data at {}/{}.json\n", cfg.vsc_data_dir.display(), name));
} else {
let max = entries.first().map(|e| e.duration_mins).unwrap_or(1);
output.push_str(&format!(" {:40} {:>8} BAR\n", "FILE", "TIME"));
output.push_str(&format!(" {}\n", "─".repeat(70)));
for e in &entries {
let bar_w = (e.duration_mins as f64 / max as f64 * 20.0).round() as usize;
output.push_str(&format!(" {:40} {:5}m {}\n",
e.file.split('/').last().unwrap_or(&e.file),
e.duration_mins,
"█".repeat(bar_w)));
}
let total_mins: u64 = entries.iter().map(|e| e.duration_mins).sum();
output.push_str(&format!("\n Total: {}h {}m\n", total_mins / 60, total_mins % 60));
}
}
}
if !output.is_empty() {
print!("{}", output);
if save {
let md = format!("# Stats\n\n```\n{}```\n", output);
save_stats_md(&root, &md)?;
println!(" ✓ Saved to {}/.fxg/stats.md", root.file_name().unwrap_or_default().to_string_lossy());
}
}
Ok(())
}
fn save_stats_md(root: &Path, content: &str) -> Result<()> {
let dir = root.join(".fxg");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("stats.md"), content)?;
Ok(())
}
fn stats_to_md(stats: &CodeStats, root: &Path) -> String {
let name = root.file_name().unwrap_or_default().to_string_lossy();
let mut md = format!("# Stats — {}\n\n", name);
md.push_str(&format!("> Generated {}\n\n", chrono::Utc::now().format("%Y-%m-%d %H:%M UTC")));
md.push_str("## Summary\n\n");
md.push_str(&format!("| Metric | Value |\n|---|---|\n"));
md.push_str(&format!("| Total lines | {} |\n", stats.total_lines));
md.push_str(&format!("| Files | {} |\n", stats.file_count));
md.push_str(&format!("| Functions | {} |\n", stats.function_count));
md.push_str(&format!("| Avg fn length | {:.1} lines |\n", stats.avg_fn_length));
md.push_str(&format!("| TODOs | {} |\n", stats.todos.len()));
md.push_str(&format!("| Security warnings | {} |\n\n", stats.sec_warnings.len()));
if !stats.languages.is_empty() {
md.push_str("## Languages\n\n| Language | Share |\n|---|---|\n");
for (lang, pct) in &stats.languages {
md.push_str(&format!("| {} | {:.1}% |\n", lang, pct));
}
md.push('\n');
}
if !stats.todos.is_empty() {
md.push_str("## TODOs\n\n| Kind | File | Line |\n|---|---|---|\n");
for t in &stats.todos {
let f = t.file.split(['/', '\\']).last().unwrap_or(&t.file);
md.push_str(&format!("| `{}` | `{}` | {} |\n", t.kind, f, t.line));
}
md.push('\n');
}
if !stats.sec_warnings.is_empty() {
md.push_str("## Security Warnings\n\n| File | Line | Reason |\n|---|---|---|\n");
for w in &stats.sec_warnings {
let f = w.file.split(['/', '\\']).last().unwrap_or(&w.file);
md.push_str(&format!("| `{}` | {} | {} |\n", f, w.line, w.reason));
}
md.push('\n');
}
md
}
+176
View File
@@ -0,0 +1,176 @@
use crate::stats::{CodeStats, SecWarning, TodoItem};
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Gauge, List, ListItem, Paragraph, Row, Table, Tabs},
Frame,
};
#[derive(Clone, Copy, PartialEq)]
enum Tab { Overview, Security, Todos, Languages }
struct State {
stats: CodeStats,
tab: Tab,
scroll: usize,
quit: bool,
}
const TABS: &[&str] = &["Overview", "Security", "TODOs", "Languages"];
fn tab_index(t: Tab) -> usize {
match t { Tab::Overview => 0, Tab::Security => 1, Tab::Todos => 2, Tab::Languages => 3 }
}
fn make_bar(value: f64, max: f64, width: usize) -> String {
if max == 0.0 { return " ".repeat(width); }
let filled = ((value / max) * width as f64).round() as usize;
let filled = filled.min(width);
format!("{}{}", "█".repeat(filled), "░".repeat(width - filled))
}
fn render_overview(f: &mut Frame, stats: &CodeStats, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(8), Constraint::Fill(1)])
.split(area);
// Summary block
let summary = vec![
Line::from(vec![Span::raw(" Lines of code : "), Span::styled(stats.total_lines.to_string(), Style::default().fg(Color::Cyan))]),
Line::from(vec![Span::raw(" Files : "), Span::raw(stats.file_count.to_string())]),
Line::from(vec![Span::raw(" Functions : "), Span::raw(format!("{} avg length: {:.1} lines", stats.function_count, stats.avg_fn_length))]),
Line::from(""),
Line::from(vec![
Span::raw(" TODOs: "),
Span::styled(stats.todos.len().to_string(), Style::default().fg(Color::Yellow)),
Span::raw(" Security warnings: "),
Span::styled(stats.sec_warnings.len().to_string(),
if stats.sec_warnings.is_empty() { Style::default().fg(Color::Green) } else { Style::default().fg(Color::Red) }),
]),
];
f.render_widget(Paragraph::new(summary).block(Block::default().title(" Summary ").borders(Borders::ALL)), chunks[0]);
// Language bars
let lang_block = Block::default().title(" Languages ").borders(Borders::ALL);
let max_pct = stats.languages.iter().map(|(_, p)| *p).fold(0.0_f64, f64::max);
let items: Vec<ListItem> = stats.languages.iter().take(8).map(|(lang, pct)| {
let bar = make_bar(*pct, max_pct, 20);
ListItem::new(format!(" {:15} {:5.1}% {}", lang, pct, bar))
}).collect();
f.render_widget(List::new(items).block(lang_block), chunks[1]);
}
fn render_security(f: &mut Frame, warnings: &[SecWarning], scroll: usize, area: Rect) {
let block = Block::default().title(format!(" Security — {} warning(s) ", warnings.len())).borders(Borders::ALL);
let items: Vec<ListItem> = warnings.iter().skip(scroll).map(|w| {
ListItem::new(format!(" ⚠ {}:{} — {}", w.file.split('/').last().unwrap_or(&w.file), w.line, w.reason))
.style(Style::default().fg(Color::Yellow))
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_todos(f: &mut Frame, todos: &[TodoItem], scroll: usize, area: Rect) {
let block = Block::default().title(format!(" TODOs / FIXMEs — {} item(s) ", todos.len())).borders(Borders::ALL);
let rows: Vec<Row> = todos.iter().skip(scroll).map(|t| {
let kind_color = match t.kind.as_str() {
"FIXME" => Color::Red, "HACK" => Color::Magenta, _ => Color::Yellow,
};
Row::new(vec![
Cell::from(t.kind.clone()).style(Style::default().fg(kind_color)),
Cell::from(t.file.split('/').last().unwrap_or(&t.file).to_string()),
Cell::from(t.line.to_string()),
Cell::from(t.text.clone()),
])
}).collect();
let widths = [Constraint::Length(7), Constraint::Length(22), Constraint::Length(6), Constraint::Fill(1)];
let header = Row::new(["KIND", "FILE", "LINE", "TEXT"])
.style(Style::default().add_modifier(Modifier::BOLD));
f.render_widget(Table::new(rows, widths).header(header).block(block), area);
}
fn render_languages(f: &mut Frame, stats: &CodeStats, area: Rect) {
let block = Block::default().title(" Language breakdown ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let _bar_height: u16 = 3;
let bars: Vec<_> = stats.languages.iter().take(6).collect();
if bars.is_empty() { return; }
let constraints: Vec<Constraint> = bars.iter().map(|_| Constraint::Fill(1)).collect();
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(inner);
let max = bars.iter().map(|(_, p)| *p).fold(0.0_f64, f64::max);
for (i, (lang, pct)) in bars.iter().enumerate() {
let gauge = Gauge::default()
.block(Block::default().title(lang.as_str()))
.gauge_style(Style::default().fg(Color::Cyan))
.ratio((pct / max).min(1.0));
f.render_widget(gauge, cols[i]);
}
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default().title(" fxg stats ").borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
// Tab bar
let tab_titles: Vec<Line> = TABS.iter().map(|t| Line::from(*t)).collect();
f.render_widget(
Tabs::new(tab_titles).select(tab_index(state.tab))
.style(Style::default())
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.block(Block::default().borders(Borders::BOTTOM)),
chunks[0],
);
match state.tab {
Tab::Overview => render_overview(f, &state.stats, chunks[1]),
Tab::Security => render_security(f, &state.stats.sec_warnings, state.scroll, chunks[1]),
Tab::Todos => render_todos(f, &state.stats.todos, state.scroll, chunks[1]),
Tab::Languages => render_languages(f, &state.stats, chunks[1]),
}
f.render_widget(
Paragraph::new(" [1-4] tabs [j/k] scroll [e] export [q] quit")
.style(Style::default().fg(Color::DarkGray)),
chunks[2],
);
}
pub fn run_stats_tui(stats: CodeStats) -> Result<()> {
let mut state = State { stats, tab: Tab::Overview, scroll: 0, quit: false };
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Tab | KeyCode::Right => state.tab = match state.tab { Tab::Overview => Tab::Security, Tab::Security => Tab::Todos, Tab::Todos => Tab::Languages, Tab::Languages => Tab::Overview },
KeyCode::BackTab | KeyCode::Left => state.tab = match state.tab { Tab::Overview => Tab::Languages, Tab::Security => Tab::Overview, Tab::Todos => Tab::Security, Tab::Languages => Tab::Todos },
KeyCode::Char('1') => { state.tab = Tab::Overview; state.scroll = 0; }
KeyCode::Char('2') => { state.tab = Tab::Security; state.scroll = 0; }
KeyCode::Char('3') => { state.tab = Tab::Todos; state.scroll = 0; }
KeyCode::Char('4') => { state.tab = Tab::Languages; state.scroll = 0; }
KeyCode::Down | KeyCode::Char('j') => state.scroll += 1,
KeyCode::Up | KeyCode::Char('k') => { if state.scroll > 0 { state.scroll -= 1; } }
_ => {}
}
}
Ok(state.quit)
})
}
+168
View File
@@ -0,0 +1,168 @@
pub mod ui;
use crate::cli::StatusArgs;
use crate::config::AppConfig;
use crate::workspace::load_workspaces;
use anyhow::Result;
use chrono::Utc;
use git2::{Repository, StatusOptions};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct RepoSummary {
pub name: String,
pub path: PathBuf,
pub branch: String,
pub ahead: usize,
pub behind: usize,
pub is_dirty: bool,
#[allow(dead_code)]
pub last_commit_msg: String,
pub last_commit_age: String,
}
#[derive(Debug, Clone)]
pub struct CommitLine {
pub sha: String,
pub msg: String,
pub age: String,
pub author: String,
}
#[derive(Debug, Clone)]
pub struct RepoDetail {
pub summary: RepoSummary,
pub modified_files: Vec<(char, String)>,
pub recent_commits: Vec<CommitLine>,
#[allow(dead_code)] pub meta_issues: Option<u32>,
#[allow(dead_code)] pub meta_prs: Option<u32>,
#[allow(dead_code)] pub meta_stars: Option<u32>,
}
// ── git helpers ───────────────────────────────────────────────────────────────
fn format_age(secs: i64) -> String {
let diff = Utc::now().timestamp() - secs;
match diff {
d if d < 3600 => format!("{}m ago", d / 60),
d if d < 86400 => format!("{}h ago", d / 3600),
d if d < 86400 * 2 => "yesterday".into(),
d => format!("{}d ago", d / 86400),
}
}
fn ahead_behind(repo: &Repository, branch: &str) -> (usize, usize) {
let local = match repo.head().and_then(|h| h.peel_to_commit()) {
Ok(c) => c.id(),
Err(_) => return (0, 0),
};
let upstream_ref = format!("refs/remotes/origin/{}", branch);
match repo.refname_to_id(&upstream_ref) {
Ok(upstream) => repo.graph_ahead_behind(local, upstream).unwrap_or((0, 0)),
Err(_) => (0, 0),
}
}
pub fn repo_summary(path: &Path) -> Result<RepoSummary> {
let repo = Repository::open(path)?;
let head = repo.head()?;
let branch = head.shorthand().unwrap_or("HEAD").to_string();
let commit = head.peel_to_commit()?;
let msg = commit.summary().unwrap_or("").chars().take(60).collect::<String>();
let age = format_age(commit.time().seconds());
let mut opts = StatusOptions::new();
opts.include_untracked(true);
let is_dirty = repo.statuses(Some(&mut opts)).map(|s| !s.is_empty()).unwrap_or(false);
let (ahead, behind) = ahead_behind(&repo, &branch);
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
Ok(RepoSummary { name, path: path.to_path_buf(), branch, ahead, behind, is_dirty, last_commit_msg: msg, last_commit_age: age })
}
pub fn repo_detail(path: &Path) -> Result<RepoDetail> {
let repo = Repository::open(path)?;
let summary = repo_summary(path)?;
// Modified files
let mut sopts = StatusOptions::new();
sopts.include_untracked(true);
let statuses = repo.statuses(Some(&mut sopts))?;
let modified_files = statuses.iter().map(|e| {
let st = e.status();
let ch = if st.contains(git2::Status::INDEX_NEW) || st.contains(git2::Status::WT_NEW) { '?' }
else if st.contains(git2::Status::INDEX_DELETED) || st.contains(git2::Status::WT_DELETED) { 'D' }
else { 'M' };
(ch, e.path().unwrap_or("").to_string())
}).collect();
// Recent commits
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
let recent_commits: Vec<CommitLine> = revwalk.take(5).filter_map(|id| {
let id = id.ok()?;
let commit = repo.find_commit(id).ok()?;
let sha = id.to_string()[..7].to_string();
let msg = commit.summary().unwrap_or("").chars().take(50).collect::<String>();
let age = format_age(commit.time().seconds());
let author = commit.author().name().unwrap_or("").to_string();
Some(CommitLine { sha, msg, age, author })
}).collect();
Ok(RepoDetail { summary, modified_files, recent_commits, meta_issues: None, meta_prs: None, meta_stars: None })
}
pub fn scan_workspace(root: &Path) -> Result<Vec<RepoSummary>> {
let entries = match std::fs::read_dir(root) {
Ok(e) => e,
Err(_) => return Ok(vec![]),
};
let mut repos = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.join(".git").exists() {
if let Ok(s) = repo_summary(&path) {
repos.push(s);
}
}
}
repos.sort_by(|a, b| a.name.cmp(&b.name));
Ok(repos)
}
pub fn apply_filter(mut repos: Vec<RepoSummary>, filter: &str) -> Vec<RepoSummary> {
repos.retain(|r| match filter {
"dirty" => r.is_dirty,
"behind" => r.behind > 0,
"ahead" => r.ahead > 0,
"clean" => !r.is_dirty && r.behind == 0,
_ => true,
});
repos
}
pub async fn run(args: StatusArgs, cfg: &AppConfig) -> Result<()> {
if let Some(repo_name) = &args.repo {
let path = cfg.workspace_root.join(repo_name);
let detail = repo_detail(&path)?;
return ui::run_detail_tui(detail);
}
if let Some(ws_name) = &args.workspace {
let wss = load_workspaces(cfg)?;
let ws = wss.iter().find(|w| &w.name == ws_name)
.ok_or_else(|| anyhow::anyhow!("workspace '{}' not found", ws_name))?;
let mut repos = Vec::new();
for path in crate::workspace::resolve_paths(ws, cfg) {
if let Ok(s) = repo_summary(&path) { repos.push(s); }
}
if let Some(f) = &args.filter { repos = apply_filter(repos, f); }
return ui::run_overview_tui(repos);
}
let mut repos = scan_workspace(&cfg.workspace_root)?;
if let Some(f) = &args.filter { repos = apply_filter(repos, f); }
ui::run_overview_tui(repos)
}
+160
View File
@@ -0,0 +1,160 @@
use crate::status::{RepoDetail, RepoSummary};
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table},
Frame,
};
// ── overview TUI ──────────────────────────────────────────────────────────────
struct OverviewState {
repos: Vec<RepoSummary>,
selected: usize,
quit: bool,
}
fn render_overview(f: &mut Frame, state: &OverviewState) {
let area = f.area();
let outer = Block::default()
.title(format!(" fxg — Workspace Status · {} repos ", state.repos.len()))
.borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
let header = Row::new(vec!["#", "NAME", "BRANCH", "AHEAD", "BEHIND", "DIRTY", "LAST COMMIT"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = state.repos.iter().enumerate().map(|(i, r)| {
let dirty = if r.is_dirty { "✓" } else { "✗" };
let row_style = if i == state.selected {
Style::default().bg(Color::DarkGray)
} else if r.is_dirty {
Style::default().fg(Color::Yellow)
} else if r.behind > 0 {
Style::default().fg(Color::Red)
} else {
Style::default()
};
Row::new(vec![
Cell::from(format!("{}", i + 1)),
Cell::from(r.name.clone()),
Cell::from(r.branch.clone()),
Cell::from(r.ahead.to_string()),
Cell::from(r.behind.to_string()),
Cell::from(dirty),
Cell::from(r.last_commit_age.clone()),
]).style(row_style)
}).collect();
let widths = [
Constraint::Length(4), Constraint::Fill(1), Constraint::Length(14),
Constraint::Length(6), Constraint::Length(7), Constraint::Length(6),
Constraint::Length(12),
];
f.render_widget(Table::new(rows, widths).header(header), chunks[0]);
f.render_widget(
Paragraph::new(" [↑/↓] navigate [Enter] drill-in [f] filter [q] quit")
.style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
}
pub fn run_overview_tui(repos: Vec<RepoSummary>) -> Result<()> {
let mut state = OverviewState { repos, selected: 0, quit: false };
tui::run_loop(|terminal| {
terminal.draw(|f| render_overview(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Up | KeyCode::Char('k') => {
if state.selected > 0 { state.selected -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if state.selected + 1 < state.repos.len() { state.selected += 1; }
}
KeyCode::Enter => {
if let Some(r) = state.repos.get(state.selected) {
if let Ok(detail) = crate::status::repo_detail(&r.path) {
// temporarily exit and re-enter for the detail view
state.quit = true;
// Note: after this loop ends we open detail TUI
let _ = run_detail_tui(detail);
}
}
}
_ => {}
}
}
Ok(state.quit)
})
}
// ── detail TUI ────────────────────────────────────────────────────────────────
fn render_detail(f: &mut Frame, detail: &RepoDetail) {
let area = f.area();
let outer = Block::default()
.title(format!(" {} · {} ", detail.summary.name, detail.summary.branch))
.borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(4), Constraint::Fill(1), Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
// Header info
let info = vec![
Line::from(vec![Span::raw(" Branch: "), Span::styled(&detail.summary.branch, Style::default().fg(Color::Cyan))]),
Line::from(vec![
Span::raw(" Status: "),
Span::raw(format!("+{} ahead -{} behind dirty: {}", detail.summary.ahead, detail.summary.behind,
if detail.summary.is_dirty { "yes" } else { "no" })),
]),
];
f.render_widget(Paragraph::new(info), chunks[0]);
// Modified files
let file_block = Block::default().title(" Modified files ").borders(Borders::ALL);
let files: Vec<ListItem> = detail.modified_files.iter()
.map(|(ch, path)| ListItem::new(format!(" {} {}", ch, path)))
.collect();
f.render_widget(List::new(files).block(file_block), chunks[1]);
// Recent commits
let commit_block = Block::default().title(" Recent commits ").borders(Borders::ALL);
let commits: Vec<ListItem> = detail.recent_commits.iter()
.map(|c| ListItem::new(format!(" {} {:<50} {} {}", c.sha, c.msg, c.age, c.author)))
.collect();
f.render_widget(List::new(commits).block(commit_block), chunks[2]);
f.render_widget(
Paragraph::new(" [p] pull [P] push [c] commit [b] branches [q] back")
.style(Style::default().fg(Color::DarkGray)),
chunks[3],
);
}
pub fn run_detail_tui(detail: RepoDetail) -> Result<()> {
let mut quit = false;
tui::run_loop(|terminal| {
terminal.draw(|f| render_detail(f, &detail))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
quit = true;
}
}
Ok(quit)
})
}
+334
View File
@@ -0,0 +1,334 @@
use crate::config::AppConfig;
use crate::cli::SummaryArgs;
use crate::stats::collect_stats;
use anyhow::Result;
use git2::Repository;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
// ── public entry point ────────────────────────────────────────────────────────
pub async fn run(args: SummaryArgs, cfg: &AppConfig) -> Result<()> {
let targets: Vec<PathBuf> = if let Some(ws_name) = &args.workspace {
let wss = crate::workspace::load_workspaces(cfg)?;
let ws = wss.iter().find(|w| w.name == *ws_name)
.ok_or_else(|| anyhow::anyhow!("workspace '{}' not found", ws_name))?;
crate::workspace::resolve_paths(ws, cfg)
} else if let Some(name) = &args.repo {
vec![cfg.workspace_root.join(name)]
} else {
vec![std::env::current_dir()?]
};
for path in &targets {
let md = build_summary(path, cfg)?;
if args.print {
println!("{}", md);
} else {
let out_dir = path.join(".fxg");
std::fs::create_dir_all(&out_dir)?;
let out_path = out_dir.join("summary.md");
std::fs::write(&out_path, &md)?;
println!(" ✓ {} → {}", path.file_name().unwrap_or_default().to_string_lossy(), out_path.display());
}
}
Ok(())
}
// ── builder ───────────────────────────────────────────────────────────────────
fn build_summary(root: &Path, _cfg: &AppConfig) -> Result<String> {
let name = root.file_name().unwrap_or_default().to_string_lossy().to_string();
let stats = collect_stats(root, None)?;
let git_info = git_summary(root);
let deps = detect_dependencies(root);
let project_type = detect_project_type(root);
let tree = dir_tree(root, 0, 3);
let mut md = String::new();
// Header
md.push_str(&format!("# {}\n\n", name));
md.push_str(&format!("> Generated by fxg on {}\n\n", chrono::Utc::now().format("%Y-%m-%d %H:%M UTC")));
// Overview table
md.push_str("## Overview\n\n");
md.push_str("| Field | Value |\n|---|---|\n");
md.push_str(&format!("| Project type | {} |\n", project_type));
md.push_str(&format!("| Total files | {} |\n", stats.file_count));
md.push_str(&format!("| Total lines | {} |\n", stats.total_lines));
md.push_str(&format!("| Functions | {} |\n", stats.function_count));
if let Some(ref gi) = git_info {
md.push_str(&format!("| Branch | `{}` |\n", gi.branch));
md.push_str(&format!("| Commits | {} |\n", gi.commit_count));
md.push_str(&format!("| Contributors | {} |\n", gi.contributors.len()));
md.push_str(&format!("| Last commit | {} |\n", gi.last_commit));
if !gi.branches.is_empty() {
md.push_str(&format!("| Branches | {} |\n", gi.branches.join(", ")));
}
}
md.push('\n');
// Languages
if !stats.languages.is_empty() {
md.push_str("## Languages\n\n");
md.push_str("| Language | Share |\n|---|---|\n");
for (lang, pct) in &stats.languages {
let bar = "█".repeat((*pct / 5.0).round() as usize);
md.push_str(&format!("| {} | {:.1}% {} |\n", lang, pct, bar));
}
md.push('\n');
}
// Dependencies
if !deps.is_empty() {
md.push_str("## Dependencies\n\n");
for (kind, list) in &deps {
md.push_str(&format!("### {}\n\n", kind));
for dep in list {
md.push_str(&format!("- `{}`\n", dep));
}
md.push('\n');
}
}
// File structure
md.push_str("## Structure\n\n");
md.push_str("```\n");
md.push_str(&tree);
md.push_str("```\n\n");
// Git contributors
if let Some(ref gi) = git_info {
if !gi.contributors.is_empty() {
md.push_str("## Contributors\n\n");
md.push_str("| Author | Commits |\n|---|---|\n");
let mut sorted: Vec<_> = gi.contributors.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
for (author, count) in sorted.iter().take(10) {
md.push_str(&format!("| {} | {} |\n", author, count));
}
md.push('\n');
}
}
// TODOs
if !stats.todos.is_empty() {
md.push_str("## TODOs & FIXMEs\n\n");
md.push_str("| Kind | File | Line | Text |\n|---|---|---|---|\n");
for t in stats.todos.iter().take(50) {
let short_file = t.file.split(['/', '\\']).last().unwrap_or(&t.file);
md.push_str(&format!("| `{}` | `{}` | {} | {} |\n",
t.kind, short_file, t.line, t.text.replace('|', "\\|")));
}
if stats.todos.len() > 50 {
md.push_str(&format!("\n_...and {} more_\n", stats.todos.len() - 50));
}
md.push('\n');
}
// Security warnings
if !stats.sec_warnings.is_empty() {
md.push_str("## Security Warnings\n\n");
md.push_str("> ⚠ These are heuristic detections and may include false positives.\n\n");
md.push_str("| File | Line | Reason |\n|---|---|---|\n");
for w in &stats.sec_warnings {
let short_file = w.file.split(['/', '\\']).last().unwrap_or(&w.file);
md.push_str(&format!("| `{}` | {} | {} |\n", short_file, w.line, w.reason));
}
md.push('\n');
}
Ok(md)
}
// ── git analysis ──────────────────────────────────────────────────────────────
struct GitInfo {
branch: String,
commit_count: usize,
last_commit: String,
contributors: HashMap<String, usize>,
branches: Vec<String>,
}
fn git_summary(root: &Path) -> Option<GitInfo> {
let repo = Repository::open(root).ok()?;
let head = repo.head().ok()?;
let branch = head.shorthand().unwrap_or("HEAD").to_string();
// Count commits and contributors
let mut revwalk = repo.revwalk().ok()?;
revwalk.push_head().ok()?;
let mut commit_count = 0usize;
let mut contributors: HashMap<String, usize> = HashMap::new();
let mut last_commit = String::from("unknown");
for (i, id) in revwalk.enumerate() {
let id = id.ok()?;
let commit = repo.find_commit(id).ok()?;
if i == 0 {
let ts = commit.time().seconds();
last_commit = format_ts(ts);
}
let author = commit.author().name().unwrap_or("unknown").to_string();
*contributors.entry(author).or_default() += 1;
commit_count += 1;
}
// List local branches
let branches: Vec<String> = repo.branches(Some(git2::BranchType::Local))
.ok()?
.filter_map(|b| b.ok())
.filter_map(|(b, _)| b.name().ok()?.map(|n| n.to_string()))
.collect();
Some(GitInfo { branch, commit_count, last_commit, contributors, branches })
}
fn format_ts(secs: i64) -> String {
use chrono::{TimeZone, Utc};
Utc.timestamp_opt(secs, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "unknown".to_string())
}
// ── dependency detection ──────────────────────────────────────────────────────
fn detect_dependencies(root: &Path) -> Vec<(String, Vec<String>)> {
let mut result = Vec::new();
// Cargo.toml
let cargo = root.join("Cargo.toml");
if cargo.exists() {
if let Ok(raw) = std::fs::read_to_string(&cargo) {
if let Ok(val) = toml::from_str::<toml::Value>(&raw) {
let mut deps = Vec::new();
for section in &["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(table) = val.get(section).and_then(|v| v.as_table()) {
for (name, ver) in table {
let ver_str = match ver {
toml::Value::String(s) => s.clone(),
toml::Value::Table(t) => t.get("version")
.and_then(|v| v.as_str())
.unwrap_or("*")
.to_string(),
_ => "*".to_string(),
};
deps.push(format!("{} = \"{}\"", name, ver_str));
}
}
}
if !deps.is_empty() {
deps.sort();
result.push(("Rust (Cargo)".to_string(), deps));
}
}
}
}
// package.json
let pkg = root.join("package.json");
if pkg.exists() {
if let Ok(raw) = std::fs::read_to_string(&pkg) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw) {
let mut deps = Vec::new();
for section in &["dependencies", "devDependencies"] {
if let Some(obj) = val.get(section).and_then(|v| v.as_object()) {
for (name, ver) in obj {
deps.push(format!("{}: {}", name, ver.as_str().unwrap_or("*")));
}
}
}
if !deps.is_empty() {
deps.sort();
result.push(("Node.js (npm/yarn)".to_string(), deps));
}
}
}
}
// requirements.txt
let reqs = root.join("requirements.txt");
if reqs.exists() {
if let Ok(raw) = std::fs::read_to_string(&reqs) {
let deps: Vec<String> = raw.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
if !deps.is_empty() {
result.push(("Python (pip)".to_string(), deps));
}
}
}
// go.mod
let gomod = root.join("go.mod");
if gomod.exists() {
if let Ok(raw) = std::fs::read_to_string(&gomod) {
let deps: Vec<String> = raw.lines()
.skip_while(|l| !l.trim_start().starts_with("require"))
.skip(1)
.take_while(|l| l.trim() != ")")
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && l != ")")
.collect();
if !deps.is_empty() {
result.push(("Go (go.mod)".to_string(), deps));
}
}
}
result
}
// ── project type detection ────────────────────────────────────────────────────
fn detect_project_type(root: &Path) -> &'static str {
if root.join("Cargo.toml").exists() { return "Rust"; }
if root.join("package.json").exists() { return "Node.js"; }
if root.join("requirements.txt").exists(){ return "Python"; }
if root.join("go.mod").exists() { return "Go"; }
if root.join("pom.xml").exists() { return "Java (Maven)"; }
if root.join("build.gradle").exists() { return "Java (Gradle)"; }
if root.join("Makefile").exists() { return "C/C++ (Make)"; }
"Unknown"
}
// ── directory tree ────────────────────────────────────────────────────────────
fn dir_tree(root: &Path, depth: usize, max_depth: usize) -> String {
if depth > max_depth { return String::new(); }
let Ok(entries) = std::fs::read_dir(root) else { return String::new() };
let indent = " ".repeat(depth);
let mut out = String::new();
let mut entries: Vec<_> = entries.flatten().collect();
entries.sort_by_key(|e| {
let is_dir = e.path().is_dir();
(if is_dir { 0 } else { 1 }, e.file_name())
});
for entry in &entries {
let path = entry.path();
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
// skip hidden/build dirs
if matches!(name.as_str(), ".git" | "target" | "node_modules" | ".next" | "dist" | ".fxg") {
continue;
}
// skip hidden files at depth 0
if depth == 0 && name.starts_with('.') {
continue;
}
if path.is_dir() {
out.push_str(&format!("{}{}/\n", indent, name));
out.push_str(&dir_tree(&path, depth + 1, max_depth));
} else {
out.push_str(&format!("{}{}\n", indent, name));
}
}
out
}
+203
View File
@@ -0,0 +1,203 @@
pub mod pr;
pub mod ui;
use crate::cli::{BranchAction, SyncAction, SyncArgs};
use crate::config::AppConfig;
use crate::workspace::{load_workspaces, resolve_paths};
use anyhow::Result;
use git2::{build::CheckoutBuilder, BranchType, IndexAddOption, Repository, Signature};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct SyncResult {
pub repo: String,
pub action: String,
pub success: bool,
pub detail: String,
}
impl SyncResult {
pub fn ok(repo: &str, action: &str, detail: impl Into<String>) -> Self {
Self { repo: repo.into(), action: action.into(), success: true, detail: detail.into() }
}
pub fn err(repo: &str, action: &str, detail: impl Into<String>) -> Self {
Self { repo: repo.into(), action: action.into(), success: false, detail: detail.into() }
}
}
fn make_callbacks(token: String) -> git2::RemoteCallbacks<'static> {
let mut cb = git2::RemoteCallbacks::new();
cb.credentials(move |_url, user, allowed| {
if allowed.contains(git2::CredentialType::SSH_KEY) { git2::Cred::ssh_key_from_agent(user.unwrap_or("git")) }
else { git2::Cred::userpass_plaintext(user.unwrap_or("git"), &token) }
});
cb
}
pub fn pull_repo(path: &Path, token: &str) -> Result<SyncResult> {
let repo = Repository::open(path)?;
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let branch = repo.head()?.shorthand().unwrap_or("main").to_string();
let mut fetch_opts = git2::FetchOptions::new();
fetch_opts.remote_callbacks(make_callbacks(token.to_string()));
let mut remote = repo.find_remote("origin")?;
remote.fetch(&[&branch], Some(&mut fetch_opts), None)?;
let fetch_head = repo.find_reference("FETCH_HEAD")?;
let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
let (analysis, _) = repo.merge_analysis(&[&fetch_commit])?;
if analysis.is_up_to_date() { return Ok(SyncResult::ok(&name, "pull", "Already up to date")); }
if analysis.is_fast_forward() {
let refname = format!("refs/heads/{}", branch);
let mut reference = repo.find_reference(&refname)?;
reference.set_target(fetch_commit.id(), "Fast-forward")?;
repo.set_head(&refname)?;
repo.checkout_head(Some(CheckoutBuilder::default().force()))?;
return Ok(SyncResult::ok(&name, "pull", "Fast-forwarded"));
}
Ok(SyncResult::err(&name, "pull", "Merge required — not fast-forward"))
}
pub fn commit_repo(path: &Path, message: &str, username: &str, email: &str) -> Result<SyncResult> {
let repo = Repository::open(path)?;
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let mut index = repo.index()?;
index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
index.write()?;
let tree = repo.find_tree(index.write_tree()?)?;
let sig = Signature::now(username, email)?;
let parent = repo.head()?.peel_to_commit()?;
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
Ok(SyncResult::ok(&name, "commit", message))
}
pub fn push_repo(path: &Path, token: &str, force_with_lease: bool) -> Result<SyncResult> {
let repo = Repository::open(path)?;
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let branch = repo.head()?.shorthand().unwrap_or("main").to_string();
let mut remote = repo.find_remote("origin")?;
let mut opts = git2::PushOptions::new();
opts.remote_callbacks(make_callbacks(token.to_string()));
let prefix = if force_with_lease { "+" } else { "" };
let refspec = format!("{}refs/heads/{1}:refs/heads/{1}", prefix, branch);
remote.push(&[&refspec], Some(&mut opts))?;
Ok(SyncResult::ok(&name, "push", format!("Pushed {}", branch)))
}
pub fn create_branch(path: &Path, name: &str) -> Result<()> {
let repo = Repository::open(path)?;
let commit = repo.head()?.peel_to_commit()?;
repo.branch(name, &commit, false)?;
Ok(())
}
pub fn delete_branch(path: &Path, name: &str) -> Result<()> {
let repo = Repository::open(path)?;
repo.find_branch(name, BranchType::Local)?.delete()?;
Ok(())
}
pub fn switch_branch(path: &Path, name: &str) -> Result<()> {
let repo = Repository::open(path)?;
let obj = repo.revparse_single(&format!("refs/heads/{}", name))?;
repo.checkout_tree(&obj, None)?;
repo.set_head(&format!("refs/heads/{}", name))?;
Ok(())
}
fn repos_for_workspace(workspace: &str, cfg: &AppConfig) -> Result<Vec<crate::status::RepoSummary>> {
let wss = load_workspaces(cfg)?;
let ws = wss.iter().find(|w| w.name == workspace)
.ok_or_else(|| anyhow::anyhow!("workspace '{}' not found", workspace))?;
let paths = resolve_paths(ws, cfg);
let mut repos = Vec::new();
for path in paths {
if let Ok(s) = crate::status::repo_summary(&path) { repos.push(s); }
}
Ok(repos)
}
fn print_result(sr: &SyncResult) {
println!(" {} {}: {}", if sr.success { "✓" } else { "✗" }, sr.repo, sr.detail);
}
pub async fn run(args: SyncArgs, cfg: &AppConfig) -> Result<()> {
let accounts = crate::auth::load_accounts(cfg).unwrap_or_default();
let token = crate::providers::default_account(&accounts, Some(&cfg.default_provider))
.and_then(|a| crate::auth::get_token(&a.id()).ok())
.unwrap_or_default();
match args.action {
None => {
let repos = crate::status::scan_workspace(&cfg.workspace_root)?;
ui::run_sync_tui(repos, cfg)
}
Some(SyncAction::Pull { all, workspace }) => {
let repos = if let Some(ws) = workspace {
repos_for_workspace(&ws, cfg)?
} else if all {
crate::status::scan_workspace(&cfg.workspace_root)?
} else {
let cwd = std::env::current_dir()?;
vec![crate::status::repo_summary(&cwd)?]
};
for r in &repos {
print_result(&pull_repo(&r.path, &token).unwrap_or_else(|e| SyncResult::err(&r.name, "pull", e.to_string())));
}
Ok(())
}
Some(SyncAction::Commit { message, all }) => {
let msg = message.unwrap_or_else(|| "chore: sync".into());
let repos = if all { crate::status::scan_workspace(&cfg.workspace_root)? }
else { vec![crate::status::repo_summary(&std::env::current_dir()?)? ] };
for r in repos.iter().filter(|r| r.is_dirty) {
print_result(&commit_repo(&r.path, &msg, &cfg.username, &cfg.email)
.unwrap_or_else(|e| SyncResult::err(&r.name, "commit", e.to_string())));
}
Ok(())
}
Some(SyncAction::Push { all, force_with_lease, message }) => {
let repos = if all { crate::status::scan_workspace(&cfg.workspace_root)? }
else { vec![crate::status::repo_summary(&std::env::current_dir()?)? ] };
// If -m given, commit dirty repos first
if let Some(msg) = &message {
for r in repos.iter().filter(|r| r.is_dirty) {
let _ = commit_repo(&r.path, msg, &cfg.username, &cfg.email);
}
}
for r in &repos {
print_result(&push_repo(&r.path, &token, force_with_lease)
.unwrap_or_else(|e| SyncResult::err(&r.name, "push", e.to_string())));
}
Ok(())
}
Some(SyncAction::Branch(bargs)) => {
match bargs.action {
BranchAction::Create { name, repos } => {
let paths: Vec<_> = if repos.is_empty() {
vec![std::env::current_dir()?]
} else {
repos.iter().map(|r| cfg.workspace_root.join(r)).collect()
};
for path in &paths {
let rname = path.file_name().unwrap_or_default().to_string_lossy();
match create_branch(path, &name) {
Ok(()) => println!(" ✓ {} — created branch {}", rname, name),
Err(e) => println!(" ✗ {}: {}", rname, e),
}
}
}
BranchAction::Delete { name } => delete_branch(&std::env::current_dir()?, &name)?,
BranchAction::Switch { name, all } => {
let repos = if all { crate::status::scan_workspace(&cfg.workspace_root)? }
else { vec![crate::status::repo_summary(&std::env::current_dir()?)? ] };
for r in &repos { let _ = switch_branch(&r.path, &name); }
}
}
Ok(())
}
Some(SyncAction::Pr(pr_args)) => pr::run(pr_args, cfg).await,
}
}
+73
View File
@@ -0,0 +1,73 @@
use crate::auth::load_accounts;
use crate::cli::{PrAction, PrArgs};
use crate::config::AppConfig;
use crate::providers::make_provider;
use anyhow::Result;
use inquire::Text;
fn open_editor_for_body() -> Result<String> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "notepad".into());
let tmp = std::env::temp_dir().join("fxg_pr_body.md");
std::fs::write(&tmp, "# PR Description\n\n")?;
std::process::Command::new(&editor).arg(&tmp).status()?;
Ok(std::fs::read_to_string(&tmp).unwrap_or_default())
}
fn current_branch() -> String {
let cwd = std::env::current_dir().unwrap_or_default();
let Ok(repo) = git2::Repository::open(&cwd) else { return "main".into() };
let Ok(head) = repo.head() else { return "main".into() };
head.shorthand().unwrap_or("main").to_string()
}
pub async fn run(args: PrArgs, cfg: &AppConfig) -> Result<()> {
match args.action {
PrAction::Create { title, base, head, reviewers, labels, repo } => {
// Resolve fields — prompt for any that are missing
let title = match title {
Some(t) => t,
None => Text::new("PR title:").prompt()?,
};
let head = match head {
Some(h) => h,
None => {
let cur = current_branch();
Text::new("Head branch:").with_default(&cur).prompt()?
}
};
let base = match base {
Some(b) => b,
None => Text::new("Base branch:").with_default("main").prompt()?,
};
let body = open_editor_for_body()?;
let repo_name = match repo {
Some(r) => r,
None => std::env::current_dir()?
.file_name().unwrap_or_default()
.to_string_lossy().to_string(),
};
let accounts = load_accounts(cfg)?;
let account = crate::providers::default_account(&accounts, None)
.ok_or_else(|| anyhow::anyhow!("no account configured"))?;
let provider = make_provider(account)?;
let pr = provider.create_pull_request(&repo_name, &title, &body, &head, &base).await?;
println!(" ✓ PR #{} created: {}", pr.number, pr.url);
if !reviewers.is_empty() {
match provider.request_reviewers(&repo_name, pr.number, &reviewers).await {
Ok(()) => println!(" ✓ Reviewers requested: {}", reviewers.join(", ")),
Err(e) => println!(" ⚠ Could not request reviewers: {}", e),
}
}
if !labels.is_empty() {
match provider.add_labels(&repo_name, pr.number, &labels).await {
Ok(()) => println!(" ✓ Labels added: {}", labels.join(", ")),
Err(e) => println!(" ⚠ Could not add labels: {}", e),
}
}
Ok(())
}
}
}
+201
View File
@@ -0,0 +1,201 @@
use crate::config::AppConfig;
use crate::status::RepoSummary;
use crate::sync::SyncResult;
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
#[derive(PartialEq)]
enum Mode { RepoSelect, ActionMenu, CommitInput, Progress }
struct State {
repos: Vec<RepoSummary>,
selected_repos: Vec<bool>,
cursor: usize,
mode: Mode,
action_cursor: usize,
commit_msg: String,
results: Vec<SyncResult>,
quit: bool,
}
const ACTIONS: &[&str] = &["Pull", "Commit", "Push", "New branch", "Create PR"];
impl State {
fn new(repos: Vec<RepoSummary>) -> Self {
let len = repos.len();
Self {
repos, selected_repos: vec![false; len],
cursor: 0, mode: Mode::RepoSelect,
action_cursor: 0, commit_msg: String::new(),
results: vec![], quit: false,
}
}
fn toggle(&mut self) {
if let Some(v) = self.selected_repos.get_mut(self.cursor) { *v = !*v; }
}
fn selected_names(&self) -> Vec<String> {
self.repos.iter().enumerate()
.filter(|(i, _)| self.selected_repos[*i])
.map(|(_, r)| r.name.clone())
.collect()
}
}
fn render_repo_select(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let block = Block::default().title(" Select repositories (Space = toggle, Enter = confirm) ").borders(Borders::ALL);
let items: Vec<ListItem> = state.repos.iter().enumerate().map(|(i, r)| {
let check = if state.selected_repos[i] { "[x]" } else { "[ ]" };
let dirty = if r.is_dirty { " ✓" } else { "" };
let style = if i == state.cursor { Style::default().bg(Color::DarkGray) } else { Style::default() };
ListItem::new(format!(" {} {:30} {}{}", check, r.name, r.branch, dirty)).style(style)
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_action_menu(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let repos = state.selected_names();
let title = format!(" Action for: {} ", repos.join(", "));
let block = Block::default().title(title).borders(Borders::ALL);
let items: Vec<ListItem> = ACTIONS.iter().enumerate().map(|(i, a)| {
let style = if i == state.action_cursor { Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) }
else { Style::default() };
ListItem::new(format!(" {}", a)).style(style)
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_commit_input(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let repos = state.selected_names();
let title = format!(" Commit message for: {} ", repos.join(", "));
let block = Block::default().title(title).borders(Borders::ALL);
let display = format!(" > {}_", state.commit_msg);
f.render_widget(Paragraph::new(display).block(block), area);
}
fn render_progress(f: &mut Frame, results: &[SyncResult], area: ratatui::layout::Rect) {
let block = Block::default().title(" Progress ").borders(Borders::ALL);
let items: Vec<ListItem> = results.iter().map(|r| {
let (icon, color) = if r.success { ("✓", Color::Green) } else { ("✗", Color::Red) };
ListItem::new(format!(" {} {} — {} — {}", icon, r.repo, r.action, r.detail))
.style(Style::default().fg(color))
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default().title(" fxg — Sync ").borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
match state.mode {
Mode::RepoSelect => render_repo_select(f, state, chunks[0]),
Mode::ActionMenu => render_action_menu(f, state, chunks[0]),
Mode::CommitInput => render_commit_input(f, state, chunks[0]),
Mode::Progress => render_progress(f, &state.results, chunks[0]),
}
let hint = match state.mode {
Mode::RepoSelect => " [Space] toggle [a] select all [Enter] confirm [q] quit",
Mode::ActionMenu => " [↑/↓] navigate [Enter] execute [Esc] back",
Mode::CommitInput => " [Type] commit message [Enter] commit [Esc] back",
Mode::Progress => " [q] quit",
};
f.render_widget(Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)), chunks[1]);
}
fn handle_key(state: &mut State, key: crossterm::event::KeyEvent, cfg: &AppConfig) {
match state.mode {
Mode::RepoSelect => match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Char('a') => state.selected_repos.iter_mut().for_each(|v| *v = true),
KeyCode::Char(' ') => state.toggle(),
KeyCode::Up | KeyCode::Char('k') => { if state.cursor > 0 { state.cursor -= 1; } }
KeyCode::Down | KeyCode::Char('j') => { if state.cursor + 1 < state.repos.len() { state.cursor += 1; } }
KeyCode::Enter => {
if state.selected_repos.iter().any(|&v| v) { state.mode = Mode::ActionMenu; }
}
_ => {}
},
Mode::ActionMenu => match key.code {
KeyCode::Esc => state.mode = Mode::RepoSelect,
KeyCode::Up | KeyCode::Char('k') => { if state.action_cursor > 0 { state.action_cursor -= 1; } }
KeyCode::Down | KeyCode::Char('j') => { if state.action_cursor + 1 < ACTIONS.len() { state.action_cursor += 1; } }
KeyCode::Enter => {
if state.action_cursor == 1 {
state.commit_msg.clear();
state.mode = Mode::CommitInput;
} else {
execute_action(state, cfg);
state.mode = Mode::Progress;
}
}
_ => {}
},
Mode::CommitInput => match key.code {
KeyCode::Esc => state.mode = Mode::ActionMenu,
KeyCode::Enter => {
if !state.commit_msg.is_empty() {
execute_action(state, cfg);
state.mode = Mode::Progress;
}
}
KeyCode::Backspace => { state.commit_msg.pop(); }
KeyCode::Char(c) => state.commit_msg.push(c),
_ => {}
},
Mode::Progress => {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) { state.quit = true; }
}
}
}
fn execute_action(state: &mut State, cfg: &AppConfig) {
let accounts = crate::auth::load_accounts(cfg).unwrap_or_default();
let token = crate::providers::default_account(&accounts, Some(&cfg.default_provider))
.and_then(|a| crate::auth::get_token(&a.id()).ok())
.unwrap_or_default();
let selected: Vec<_> = state.repos.iter()
.enumerate()
.filter(|(i, _)| state.selected_repos[*i])
.map(|(_, r)| r.clone())
.collect();
for repo in &selected {
let result = match state.action_cursor {
0 => crate::sync::pull_repo(&repo.path, &token)
.unwrap_or_else(|e| SyncResult::err(&repo.name, "pull", e.to_string())),
1 => crate::sync::commit_repo(&repo.path, &state.commit_msg, &cfg.username, &cfg.email)
.unwrap_or_else(|e| SyncResult::err(&repo.name, "commit", e.to_string())),
2 => crate::sync::push_repo(&repo.path, &token, false)
.unwrap_or_else(|e| SyncResult::err(&repo.name, "push", e.to_string())),
_ => SyncResult::err(&repo.name, ACTIONS[state.action_cursor],
"niedostepne w TUI — uzyj: fxg sync branch create <name> / fxg sync pr create"),
};
state.results.push(result);
}
}
pub fn run_sync_tui(repos: Vec<RepoSummary>, cfg: &AppConfig) -> Result<()> {
let mut state = State::new(repos);
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
handle_key(&mut state, key, cfg);
}
Ok(state.quit)
})
}
+84
View File
@@ -0,0 +1,84 @@
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
Terminal,
};
use std::{io, time::Duration};
pub type Tty = Terminal<CrosstermBackend<io::Stdout>>;
/// Set up raw-mode alternate-screen terminal.
pub fn setup() -> Result<Tty> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
Ok(Terminal::new(backend)?)
}
/// Restore terminal to normal mode.
pub fn teardown(mut terminal: Tty) -> Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
/// Poll for the next crossterm event with a timeout.
pub fn next_event(timeout_ms: u64) -> Result<Option<Event>> {
if event::poll(Duration::from_millis(timeout_ms))? {
Ok(Some(event::read()?))
} else {
Ok(None)
}
}
/// Return a centred `Rect` inside `area` using percentage sizes.
#[allow(dead_code)]
pub fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
])
.split(vert[1])[1]
}
/// Run `draw_fn` inside a properly-torn-down terminal.
/// The closure returns `true` to quit, `false` to keep running.
pub fn run_loop<F>(mut draw_fn: F) -> Result<()>
where
F: FnMut(&mut Tty) -> Result<bool>,
{
let mut terminal = setup()?;
let result = (|| -> Result<()> {
loop {
if draw_fn(&mut terminal)? {
break;
}
}
Ok(())
})();
teardown(terminal)?;
result
}
+124
View File
@@ -0,0 +1,124 @@
use crate::cli::{WorkspaceAction, WorkspaceArgs};
use crate::config::AppConfig;
use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub name: String,
pub repos: Vec<String>,
pub created_at: String,
}
#[derive(Serialize, Deserialize, Default)]
struct WorkspacesFile {
workspaces: Vec<Workspace>,
}
pub fn workspaces_path(cfg: &AppConfig) -> PathBuf {
cfg.data_dir.join("workspaces.toml")
}
pub fn load_workspaces(cfg: &AppConfig) -> Result<Vec<Workspace>> {
let path = workspaces_path(cfg);
if path.exists() {
let raw = std::fs::read_to_string(&path)?;
let file: WorkspacesFile = toml::from_str(&raw)?;
Ok(file.workspaces)
} else {
Ok(vec![])
}
}
pub fn save_workspaces(workspaces: &[Workspace], cfg: &AppConfig) -> Result<()> {
let path = workspaces_path(cfg);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let file = WorkspacesFile { workspaces: workspaces.to_vec() };
std::fs::write(path, toml::to_string_pretty(&file)?)?;
Ok(())
}
pub fn create_workspace(name: &str, workspaces: &mut Vec<Workspace>, cfg: &AppConfig) -> Result<()> {
anyhow::ensure!(!workspaces.iter().any(|w| w.name == name), "workspace '{}' already exists", name);
workspaces.push(Workspace {
name: name.to_string(),
repos: vec![],
created_at: Utc::now().to_rfc3339(),
});
save_workspaces(workspaces, cfg)?;
Ok(())
}
pub fn add_repos_to_workspace(
name: &str,
repos: &[String],
workspaces: &mut Vec<Workspace>,
cfg: &AppConfig,
) -> Result<()> {
let ws = workspaces
.iter_mut()
.find(|w| w.name == name)
.ok_or_else(|| anyhow::anyhow!("workspace '{}' not found", name))?;
for repo in repos {
if !ws.repos.contains(repo) {
ws.repos.push(repo.clone());
}
}
save_workspaces(workspaces, cfg)?;
Ok(())
}
pub fn remove_workspace(name: &str, workspaces: &mut Vec<Workspace>, cfg: &AppConfig) -> Result<()> {
let before = workspaces.len();
workspaces.retain(|w| w.name != name);
anyhow::ensure!(workspaces.len() < before, "workspace '{}' not found", name);
save_workspaces(workspaces, cfg)?;
Ok(())
}
/// Map repo names to absolute paths under workspace_root.
pub fn resolve_paths(ws: &Workspace, cfg: &AppConfig) -> Vec<PathBuf> {
ws.repos.iter()
.map(|r| cfg.workspace_root.join(r))
.filter(|p| p.exists())
.collect()
}
fn print_workspace_table(workspaces: &[Workspace]) {
println!("{:<20} {:<6} {}", "NAME", "REPOS", "CREATED");
println!("{}", "─".repeat(50));
for ws in workspaces {
println!("{:<20} {:<6} {}", ws.name, ws.repos.len(), &ws.created_at[..10]);
}
}
pub async fn run(args: WorkspaceArgs, cfg: &AppConfig) -> Result<()> {
let mut workspaces = load_workspaces(cfg)?;
match args.action {
None | Some(WorkspaceAction::List) => {
if workspaces.is_empty() {
println!("No workspaces yet. Use: fxg workspace create <name>");
} else {
print_workspace_table(&workspaces);
}
}
Some(WorkspaceAction::Create { name }) => {
create_workspace(&name, &mut workspaces, cfg)?;
println!("✓ Workspace '{}' created", name);
}
Some(WorkspaceAction::Add { workspace, repos }) => {
add_repos_to_workspace(&workspace, &repos, &mut workspaces, cfg)?;
println!("✓ Added {} repos to workspace '{}'", repos.len(), workspace);
}
Some(WorkspaceAction::Remove { name }) => {
remove_workspace(&name, &mut workspaces, cfg)?;
println!("✓ Workspace '{}' removed", name);
}
}
Ok(())
}