init
This commit is contained in:
commit
4cf726709f
36 files changed
+8617
No files matched your search
+10
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
*.env
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.DS_Store
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
Generated
+2649
File diff suppressed because it is too large.
Load diff
+26
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "fxg"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Multi-repo git workflow manager"
|
||||
|
||||
[[bin]]
|
||||
name = "fxg"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
crossterm = "0.28"
|
||||
dirs = "5"
|
||||
git2 = "0.19"
|
||||
inquire = "0.7"
|
||||
keyring = "3"
|
||||
ratatui = "0.28"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
toml = "0.8"
|
||||
@@ -0,0 +1,322 @@
|
||||
# fxg
|
||||
|
||||
Multi-repository git workflow manager. Zarządzaj repozytoriami na GitHub, GitLab i Gitea z jednego miejsca.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Linux — opcjonalna zależność systemowa
|
||||
sudo apt install libgit2-dev # Ubuntu/Debian
|
||||
sudo pacman -S libgit2 # Arch
|
||||
|
||||
cargo install --path .
|
||||
fxg --version
|
||||
```
|
||||
|
||||
Binarny plik ląduje w `~/.cargo/bin/fxg` — już jest w PATH po standardowej instalacji Rust. Działa na Windows i Linux.
|
||||
|
||||
---
|
||||
|
||||
## Pierwsze uruchomienie
|
||||
|
||||
```bash
|
||||
fxg config # ustaw workspace_root, username, email
|
||||
fxg auth add --provider github --token ghp_xxxxx
|
||||
```
|
||||
|
||||
Bez tych dwóch kroków większość komend nie zadziała.
|
||||
|
||||
---
|
||||
|
||||
## `fxg status`
|
||||
|
||||
Otwiera TUI — tabelę wszystkich repozytoriów z `workspace_root`:
|
||||
|
||||
```
|
||||
┌ fxg — Workspace Status · 4 repos ──────────────────────────────────────────────┐
|
||||
│ # NAME BRANCH AHEAD BEHIND DIRTY LAST COMMIT │
|
||||
│ 1 my-api main 2 0 ✗ 2h ago │
|
||||
│ 2 my-frontend develop 0 3 ✓ yesterday ← żółty │
|
||||
│ 3 shared-lib main 0 0 ✗ 5d ago │
|
||||
│ 4 infra feat/k8s 1 0 ✓ 30m ago ← żółty │
|
||||
│ │
|
||||
│ [↑/↓] navigate [Enter] drill-in [f] filter [q] quit │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Kolory: żółty = dirty (niezcommitowane zmiany), czerwony = behind (trzeba pullować).
|
||||
|
||||
**Enter na repozytorium** → widok szczegółowy:
|
||||
|
||||
```
|
||||
┌ my-frontend · develop ─────────────────────────────────────────────────────────┐
|
||||
│ Branch: develop │
|
||||
│ Status: +0 ahead -3 behind dirty: yes │
|
||||
│ │
|
||||
│ ┌ Modified files ──────────────────────────┐ ┌ Recent commits ───────────────┐ │
|
||||
│ │ M src/components/Nav.tsx │ │ a3f1c2 fix: nav style 2h │ │
|
||||
│ │ M src/styles/theme.css │ │ b89d01 feat: dark mode 1d │ │
|
||||
│ │ ? src/utils/new-helper.ts │ │ c12345 chore: deps 3d │ │
|
||||
│ └──────────────────────────────────────────┘ └───────────────────────────────┘ │
|
||||
│ [p] pull [P] push [c] commit [b] branches [q] back │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Filtry (z CLI):**
|
||||
```bash
|
||||
fxg status --filter dirty # tylko repo z niezcommitowanymi zmianami
|
||||
fxg status --filter behind # tylko repo, które trzeba pullować
|
||||
fxg status --filter ahead # tylko repo gotowe do pusha
|
||||
fxg status --filter clean # tylko czyste i zsynchronizowane
|
||||
fxg status --workspace work # tylko repo w danym workspace
|
||||
fxg status my-api # bezpośrednio widok szczegółowy konkretnego repo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `fxg sync`
|
||||
|
||||
Bez argumentów otwiera TUI — **3-etapowy flow**:
|
||||
|
||||
### Etap 1 — wybór repozytoriów
|
||||
|
||||
```
|
||||
┌ Select repositories (Space = toggle, Enter = confirm) ──────────────────────┐
|
||||
│ [ ] my-api main │
|
||||
│ [x] my-frontend develop ✓ ← dirty │
|
||||
│ [ ] shared-lib main │
|
||||
│ [x] infra feat/k8s ✓ ← dirty │
|
||||
│ │
|
||||
│ [Space] toggle [a] select all [Enter] confirm [q] quit │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`Space` = toggle zaznaczenia, `a` = zaznacz wszystkie, `Enter` = przejdź dalej.
|
||||
|
||||
### Etap 2 — wybór akcji
|
||||
|
||||
```
|
||||
┌ Action for: my-frontend, infra ────────────────────────────────────────────┐
|
||||
│ Pull │
|
||||
│ Commit │
|
||||
│ ▶ Push │
|
||||
│ New branch │
|
||||
│ Create PR │
|
||||
│ │
|
||||
│ [↑/↓] navigate [Enter] execute [Esc] back │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Etap 3 — wyniki
|
||||
|
||||
```
|
||||
┌ Progress ──────────────────────────────────────────────────────────────────┐
|
||||
│ ✓ my-frontend — push — Pushed develop │
|
||||
│ ✓ infra — push — Pushed feat/k8s │
|
||||
│ │
|
||||
│ [q] quit │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Sync bez TUI (skrypty / CI)
|
||||
|
||||
```bash
|
||||
fxg sync pull --workspace work # pull wszystkich repo w workspace
|
||||
fxg sync pull --all # pull wszystkich repo w workspace_root
|
||||
|
||||
fxg sync commit -m "chore: sync" # commit wszystkich dirty repo (CWD)
|
||||
fxg sync commit -m "chore: sync" --all # commit wszystkich dirty w workspace_root
|
||||
|
||||
fxg sync push --all # push wszystkich
|
||||
fxg sync push --all -m "chore: sync" # najpierw commit dirty, potem push
|
||||
fxg sync push --all --force-with-lease # force push (bezpieczny)
|
||||
```
|
||||
|
||||
> **Uwaga:** `commit` używa tej samej wiadomości dla wszystkich repo. Indywidualne wiadomości per-repo możliwe przez TUI (opcja Commit) — TUI pyta o wiadomość przed wykonaniem commita.
|
||||
|
||||
---
|
||||
|
||||
## `fxg init`
|
||||
|
||||
Bez flagi `--provider` uruchamia **wizard 7-krokowy**:
|
||||
|
||||
```
|
||||
══════════════════════════════════════════
|
||||
fxg — New Repository
|
||||
══════════════════════════════════════════
|
||||
|
||||
Step 1/7 · Repository name: my-new-project
|
||||
> walidacja: brak spacji, niepuste
|
||||
|
||||
Step 2/7 · Provider & Account:
|
||||
> github/personal ← domyślny (zaznaczony)
|
||||
gitlab/work
|
||||
|
||||
Step 3/7 · Visibility:
|
||||
> private
|
||||
public
|
||||
internal
|
||||
|
||||
Step 4/7 · Tags (comma-separated):
|
||||
> rust, cli, tools
|
||||
|
||||
Step 5/7 · Initial files: ← MultiSelect, domyślnie 4 pierwsze zaznaczone
|
||||
[x] README.md
|
||||
[x] .gitignore
|
||||
[x] CHANGELOG.md
|
||||
[x] .env.example
|
||||
[ ] LICENSE ← opcja dostępna ale plik NIE jest generowany (TODO)
|
||||
[ ] CONTRIBUTING.md ← j.w.
|
||||
|
||||
Template:
|
||||
> node
|
||||
rust ← dla Rusta generuje /target\nCargo.lock\n
|
||||
python
|
||||
blank
|
||||
|
||||
Step 6/7 · Extra branches (comma-separated):
|
||||
> dev ← domyślnie
|
||||
|
||||
Step 7/7 · Add collaborators? (y/N)
|
||||
> username: alice
|
||||
role: write
|
||||
> Add another? N
|
||||
|
||||
──────────────────────────────────────────
|
||||
Proceed with creation? (Y/n)
|
||||
```
|
||||
|
||||
**Co się dzieje po potwierdzeniu:**
|
||||
|
||||
```
|
||||
✓ Created remote repo my-new-project (github/personal)
|
||||
✓ Applied tags: rust, cli, tools
|
||||
✓ mkdir ~/projects/my-new-project
|
||||
✓ git init
|
||||
✓ Created initial files
|
||||
✓ Initial commit: "chore: init"
|
||||
✓ git remote add origin git@github.com:user/my-new-project.git
|
||||
✓ git push -u origin main
|
||||
✓ Created branch: dev
|
||||
✓ Added collaborator: alice (write)
|
||||
|
||||
Done in 2.3s · https://github.com/user/my-new-project
|
||||
```
|
||||
|
||||
**Tryb bez wizarda** (name + provider podane → pomija wizard):
|
||||
```bash
|
||||
fxg init my-project --provider github
|
||||
fxg init my-project --provider github --status public --tags rust,cli --branch develop
|
||||
```
|
||||
|
||||
**Batch** — wiele repo naraz:
|
||||
```bash
|
||||
fxg init --batch "api,frontend,shared-lib" # lista inline
|
||||
fxg init --batch repos.txt # plik, jedno repo per linia
|
||||
```
|
||||
W trybie batch: tworzy README, .gitignore, bez CHANGELOG i .env.example.
|
||||
|
||||
---
|
||||
|
||||
## `fxg workspace`
|
||||
|
||||
Grupy repozytoriów do zbiorczych operacji.
|
||||
|
||||
```bash
|
||||
fxg workspace create backend
|
||||
fxg workspace add backend api shared-lib
|
||||
fxg workspace list
|
||||
fxg workspace remove backend
|
||||
```
|
||||
|
||||
```
|
||||
Workspaces:
|
||||
backend (2 repos) api, shared-lib
|
||||
frontend (1 repo) my-frontend
|
||||
```
|
||||
|
||||
Workspace używasz potem w innych komendach: `--workspace backend`.
|
||||
|
||||
---
|
||||
|
||||
## `fxg auth`
|
||||
|
||||
```bash
|
||||
fxg auth # TUI z listą kont
|
||||
fxg auth add --provider github --token ghp_xxx # dodaj konto
|
||||
fxg auth add --provider gitlab --token glpat_xxx
|
||||
fxg auth add --provider gitea --token xxx --url https://git.example.com
|
||||
fxg auth add --provider github --token ghp_xxx --alias work # nazwany alias
|
||||
fxg auth remove github/personal
|
||||
fxg auth switch github/work # ustaw jako domyślne
|
||||
fxg auth test # sprawdź domyślne konto
|
||||
fxg auth test github/work
|
||||
```
|
||||
|
||||
ID konta: `<provider>/<alias>`, np. `github/personal`, `gitea/selfhosted`.
|
||||
Tokeny przechowywane w systemowym keychaine (Windows Credential Manager / macOS Keychain / Linux Secret Service) — nigdy w plikach plaintext.
|
||||
|
||||
---
|
||||
|
||||
## `fxg config`
|
||||
|
||||
Interaktywne TUI do edycji ustawień globalnych. Zapisywane do `~/.fxg/config.toml`.
|
||||
|
||||
```bash
|
||||
fxg config
|
||||
```
|
||||
|
||||
Plik konfiguracyjny (`~/.fxg/config.toml`):
|
||||
|
||||
```toml
|
||||
workspace_root = "/home/user/projects"
|
||||
data_dir = "/home/user/.fxg"
|
||||
default_provider = "github"
|
||||
default_branch = "main"
|
||||
default_visibility = "private"
|
||||
editor = "vim"
|
||||
username = "your-name"
|
||||
email = "you@example.com"
|
||||
gitignore_template = "node"
|
||||
readme_template = "heartbyte-default"
|
||||
share_stats = false
|
||||
timezone = "Europe/Warsaw"
|
||||
vsc_data_dir = "/home/user/.fxg/vsc-data"
|
||||
```
|
||||
|
||||
| Pole | Domyślnie | Opis |
|
||||
|---|---|---|
|
||||
| `workspace_root` | `~/projects` | Gdzie fxg szuka lokalnych repozytoriów |
|
||||
| `data_dir` | `~/.fxg` | Dane fxg (konta, workspace'y) |
|
||||
| `default_provider` | `github` | Provider gdy nie podasz `--provider` |
|
||||
| `default_branch` | `main` | Domyślna gałąź nowych repo |
|
||||
| `default_visibility` | `private` | Widoczność nowych repo |
|
||||
| `editor` | `$EDITOR` lub `vim` | Edytor w interaktywnych flowach |
|
||||
| `username` | _(puste)_ | Git username — używany w commitach |
|
||||
| `email` | _(puste)_ | Git email — używany w commitach |
|
||||
| `gitignore_template` | `node` | Template `.gitignore` (`node`, `rust`, `python`) |
|
||||
| `timezone` | `Europe/Warsaw` | Strefa czasowa w statystykach |
|
||||
| `vsc_data_dir` | `~/.fxg/vsc-data` | Dane VSCode time tracking |
|
||||
|
||||
---
|
||||
|
||||
## Pliki danych
|
||||
|
||||
```
|
||||
~/.fxg/
|
||||
├── config.toml # ustawienia globalne
|
||||
├── accounts.toml # konta (bez tokenów — te są w keychaine)
|
||||
├── workspaces.toml # definicje workspace'ów
|
||||
└── vsc-data/ # dane śledzenia czasu z VSCode (dla fxg stats time)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Providery
|
||||
|
||||
| Provider | wartość `--provider` | Uwagi |
|
||||
|---|---|---|
|
||||
| GitHub | `github` | github.com |
|
||||
| GitLab | `gitlab` | gitlab.com |
|
||||
| Gitea | `gitea` | self-hosted, wymaga `--url` |
|
||||
| Forgejo | `forgejo` | fork Gitea, wymaga `--url` |
|
||||
@@ -0,0 +1,282 @@
|
||||
# fxg — strategia cross-platform
|
||||
|
||||
> Cel: jedna binarka, trzy systemy, instalacja w 30 sekund, zero wiedzy technicznej wymaganej.
|
||||
|
||||
---
|
||||
|
||||
## Stan obecny
|
||||
|
||||
Rust kompiluje się do natywnych binarek bez zewnętrznych zależności runtime. To dobra podstawa — nie trzeba instalować Pythona, Node.js ani JVM. Ale dziś jedynym sposobem instalacji jest `cargo install --path .`, co wymaga zainstalowanego Rusta i środowiska build. Dla grafika — nierealne.
|
||||
|
||||
Obecny problem z `keyring`: na Linuksie wymaga `libdbus` lub `libsecret`. Do rozwiązania przy pakowaniu.
|
||||
|
||||
---
|
||||
|
||||
## Cel UX instalacji
|
||||
|
||||
**Windows:** Pobierasz plik `.exe`. Klikasz go dwukrotnie. Otwiera się terminal z konfiguratorem. Po 2 minutach piszesz `fxg status` i to działa.
|
||||
|
||||
**Linux:** Wklejasz jedną linię w terminal. Po 10 sekundach piszesz `fxg status` i to działa.
|
||||
|
||||
**macOS:** `brew install`. Jedna komenda.
|
||||
|
||||
---
|
||||
|
||||
## Strategia dystrybucji — GitHub Releases + CI
|
||||
|
||||
### GitHub Actions — automatyczne buildowanie binarek
|
||||
|
||||
Przy każdym tagu (`v*.*.*`) CI buduje binarkę na 4 targety:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/release.yml
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl # statyczna binarka — działa na każdym Linuksie
|
||||
artifact: fxg-linux-x64
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl # ARM (np. Raspberry Pi, nowe serwery)
|
||||
artifact: fxg-linux-arm64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact: fxg-windows-x64.exe
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact: fxg-macos-x64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin # Apple Silicon (M1/M2/M3)
|
||||
artifact: fxg-macos-arm64
|
||||
```
|
||||
|
||||
> Używamy `x86_64-unknown-linux-musl` zamiast `gnu` — binarka statyczna, nie wymaga żadnych systemowych `.so`. Keyring na Linuksie fallback do pliku szyfrowanego (jeśli brak dbus).
|
||||
|
||||
Pliki lądują w GitHub Releases jako Assets. Użytkownik pobiera odpowiedni plik dla swojego systemu.
|
||||
|
||||
---
|
||||
|
||||
## Linux — instalacja
|
||||
|
||||
### Skrypt jednolinijkowy (docelowy UX)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://github.com/twoj-user/fxg/releases/latest/download/install.sh | bash
|
||||
```
|
||||
|
||||
Skrypt robi:
|
||||
1. Wykrywa architekturę (`uname -m`)
|
||||
2. Pobiera odpowiednią binarkę z GitHub Releases
|
||||
3. Umieszcza ją w `~/.local/bin/fxg`
|
||||
4. Dodaje `~/.local/bin` do `$PATH` w `.bashrc`/`.zshrc` jeśli jeszcze nie ma
|
||||
5. Jeśli brak konfiguracji → uruchamia `fxg setup` (first-run wizard)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
ARCH=$(uname -m)
|
||||
OS=linux
|
||||
case $ARCH in
|
||||
x86_64) FILE="fxg-linux-x64" ;;
|
||||
aarch64) FILE="fxg-linux-arm64" ;;
|
||||
*) echo "Nieobsługiwana architektura: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
LATEST=$(curl -s https://api.github.com/repos/twoj-user/fxg/releases/latest | grep tag_name | cut -d '"' -f 4)
|
||||
URL="https://github.com/twoj-user/fxg/releases/download/${LATEST}/${FILE}"
|
||||
|
||||
mkdir -p ~/.local/bin
|
||||
curl -fsSL "$URL" -o ~/.local/bin/fxg
|
||||
chmod +x ~/.local/bin/fxg
|
||||
|
||||
# Dodaj do PATH jeśli brak
|
||||
if ! echo "$PATH" | grep -q "$HOME/.local/bin"; then
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
|
||||
echo "fxg zainstalowany. Uruchamianie konfiguracji..."
|
||||
fxg setup
|
||||
```
|
||||
|
||||
### Opcjonalnie: pakiety .deb i .rpm
|
||||
|
||||
Generowane przez `cargo-deb` i `cargo-rpm` w CI — przydatne dla systemów zarządzanych przez IT.
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo dpkg -i fxg_0.1.0_amd64.deb
|
||||
|
||||
# Fedora/RHEL/CentOS
|
||||
sudo rpm -i fxg-0.1.0.x86_64.rpm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Windows — instalacja
|
||||
|
||||
### Opcja A: Kliknięcie `.exe` (docelowy UX dla grafika)
|
||||
|
||||
Gdy użytkownik dwukrotnie kliknie `fxg-windows-x64.exe` bez żadnych argumentów i brak konfiguracji, zamiast wyświetlić help i zamknąć okno — **otwiera PowerShell z konfiguratorem**.
|
||||
|
||||
Implementacja w `src/main.rs`:
|
||||
```rust
|
||||
#[cfg(windows)]
|
||||
fn ensure_console() {
|
||||
// Jeśli uruchomiono przez dwuklik (nie z terminala), otwórz nowe okno konsoli
|
||||
// i uruchom fxg setup w nim
|
||||
use std::os::windows::process::CommandExt;
|
||||
if !is_launched_from_terminal() {
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/k", "fxg.exe", "setup"])
|
||||
.creation_flags(0x00000010) // CREATE_NEW_CONSOLE
|
||||
.spawn().ok();
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Opcja B: Skrypt PowerShell (instalacja systemowa)
|
||||
|
||||
```powershell
|
||||
# Wklej w PowerShell jako Administrator:
|
||||
iwr https://github.com/twoj-user/fxg/releases/latest/download/install.ps1 | iex
|
||||
```
|
||||
|
||||
Skrypt:
|
||||
1. Pobiera `fxg-windows-x64.exe`
|
||||
2. Umieszcza w `%USERPROFILE%\AppData\Local\fxg\`
|
||||
3. Dodaje ten folder do `$PATH` (przez `[Environment]::SetEnvironmentVariable`)
|
||||
4. Uruchamia `fxg setup` w nowym oknie terminalu
|
||||
|
||||
### Opcja C: WinGet (Microsoft Store CLI) — przyszłość
|
||||
|
||||
```
|
||||
winget install fxg
|
||||
```
|
||||
|
||||
Wymaga zgłoszenia pakietu do repozytorium `winget-pkgs`.
|
||||
|
||||
---
|
||||
|
||||
## macOS — instalacja
|
||||
|
||||
### Homebrew (docelowy UX)
|
||||
|
||||
```bash
|
||||
brew install twoj-user/tap/fxg
|
||||
```
|
||||
|
||||
Wymaga stworzenia własnego "tap" (repozytorium z formularzem Homebrew):
|
||||
|
||||
```ruby
|
||||
# Formula/fxg.rb
|
||||
class Fxg < Formula
|
||||
desc "Multi-repository git workflow manager"
|
||||
homepage "https://github.com/twoj-user/fxg"
|
||||
version "0.1.0"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/twoj-user/fxg/releases/download/v0.1.0/fxg-macos-arm64"
|
||||
sha256 "..."
|
||||
else
|
||||
url "https://github.com/twoj-user/fxg/releases/download/v0.1.0/fxg-macos-x64"
|
||||
sha256 "..."
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
bin.install "fxg-macos-arm64" => "fxg"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## First-run wizard
|
||||
|
||||
Kluczowy element UX. Gdy użytkownik uruchamia **dowolną komendę** po raz pierwszy (brak `~/.fxg/config.toml`), zamiast error'a dostaje automatyczny setup.
|
||||
|
||||
Implementacja w `src/app.rs`, przed każdym `match cli.command`:
|
||||
|
||||
```rust
|
||||
// src/app.rs
|
||||
pub async fn run(cli: Cli) -> Result<()> {
|
||||
// Jeśli brak konfiguracji → uruchom first-run setup
|
||||
if !config::config_path().exists() {
|
||||
println!("Wygląda na to, że uruchamiasz fxg po raz pierwszy.");
|
||||
println!("Przeprowadźmy krótką konfigurację (zajmie ~2 minuty).\n");
|
||||
first_run_setup().await?;
|
||||
}
|
||||
|
||||
let cfg = config::load_config()?;
|
||||
// ... reszta
|
||||
}
|
||||
|
||||
async fn first_run_setup() -> Result<()> {
|
||||
// 1. Uruchom config TUI
|
||||
let cfg = config::run_first_time().await?;
|
||||
// 2. Zapytaj o dodanie konta
|
||||
if inquire::Confirm::new("Czy chcesz teraz podłączyć konto GitHub/GitLab?")
|
||||
.with_default(true).prompt()? {
|
||||
// Uruchom auth add wizard
|
||||
auth::run_add_wizard(&cfg).await?;
|
||||
}
|
||||
println!("\nGotowe! Wpisz 'fxg status' żeby zobaczyć swoje projekty.");
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Systemowe zależności — problemy i rozwiązania
|
||||
|
||||
| Zależność | Problem | Rozwiązanie |
|
||||
|---|---|---|
|
||||
| `libgit2` | Wymagana na Linuksie przy dynamicznym linkowaniu | Używamy `git2` z feature `vendored` — kompiluje libgit2 statycznie |
|
||||
| `keyring` / `libsecret` | Wymaga `dbus` na Linuksie headless | Fallback do zaszyfrowanego pliku `~/.fxg/credentials.enc` gdy brak dbus |
|
||||
| `openssl` | Różne wersje na różnych systemach | Feature `native-tls` → `rustls` — TLS w całości wbudowany w Rust |
|
||||
|
||||
```toml
|
||||
# Cargo.toml — docelowa konfiguracja dla statycznych binarek
|
||||
[dependencies]
|
||||
git2 = { version = "0.19", features = ["vendored"] }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
keyring = { version = "3", features = ["sync-secret-service"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## O nazwie
|
||||
|
||||
Aktualna nazwa `fxg` ("finally executable git") to placeholder — dobra jako codename, ale przed publicznym wydaniem warto rozważyć zmianę.
|
||||
|
||||
Kryteria dobrej nazwy narzędzia CLI:
|
||||
- Krótka (2–4 znaki idealne, max 6)
|
||||
- Łatwa do wymówienia i zapamiętania
|
||||
- Nie koliduje z istniejącymi komendami systemowymi
|
||||
- Nawiązuje do funkcji (opcjonalnie)
|
||||
|
||||
Kilka luźnych kierunków (bez rekomendacji — ostateczna decyzja należy do Ciebie):
|
||||
- Coś od "workspace" / "repos" / "git"
|
||||
- Coś sugerującego "wielość" lub "hub"
|
||||
- Akronim który znaczy coś po polsku, a po angielsku brzmi jak komenda
|
||||
|
||||
Niezależnie od wyboru nazwy — warto go podjąć przed stworzeniem Homebrew tap i pierwszym publicznym Release, bo zmiana nazwy binarki po publikacji jest uciążliwa.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap dystrybucji
|
||||
|
||||
```
|
||||
v0.1 (teraz) cargo install — tylko dla developerów
|
||||
v0.2 GitHub Releases z binaries (Linux + Windows + macOS)
|
||||
Skrypt instalacyjny curl | bash
|
||||
v0.3 First-run wizard (bez potrzeby wpisywania komend setup)
|
||||
PowerShell install script dla Windows
|
||||
v0.4 Homebrew tap dla macOS
|
||||
v1.0 Stabilny API, binarka klikalna na Windows
|
||||
WinGet (opcjonalnie)
|
||||
Pakiety .deb/.rpm (opcjonalnie)
|
||||
```
|
||||
@@ -0,0 +1,198 @@
|
||||
# Poradnik dla grafika — jak używać fxg
|
||||
|
||||
> Ten poradnik zakłada, że nigdy wcześniej nie używałeś terminalu do pracy z kodem. Spokojnie — to jest prostsze niż myślisz.
|
||||
|
||||
---
|
||||
|
||||
## Co to jest i po co to komuś?
|
||||
|
||||
Wyobraź sobie, że każdy projekt (strona, aplikacja, assets) mieszka w osobnym folderze na GitHubie. `fxg` to program, który pozwala ci zarządzać tymi wszystkimi folderami jednocześnie — zobaczyć co się zmieniło, wysłać zmiany na serwer, pobrać najnowszą wersję — wszystko bez klikania po interfejsie GitHuba.
|
||||
|
||||
**Dla Ciebie oznacza to:** zamiast wchodzić na stronę GitHuba i klikać w każde repozytorium osobno, wpisujesz jedną komendę i widzisz status wszystkiego.
|
||||
|
||||
---
|
||||
|
||||
## Krok 1 — Instalacja
|
||||
|
||||
### Windows
|
||||
|
||||
1. Pobierz plik `fxg.exe` z sekcji [Releases](https://github.com/twoj-team/fxg/releases) (zakładka "Assets")
|
||||
2. Przenieś plik do folderu `C:\Users\TwojeImie\` (czyli do swojego folderu domowego)
|
||||
3. Otwórz **Terminal** (wyszukaj "Terminal" w menu Start, lub `Windows + R` → wpisz `cmd` → Enter)
|
||||
4. Wpisz `fxg --version` i naciśnij Enter — jeśli widzisz numer wersji, instalacja się udała
|
||||
|
||||
> Jeśli system pyta "Czy chcesz uruchomić ten plik?" — kliknij "Tak" / "Uruchom mimo to".
|
||||
|
||||
### Linux (np. Linux Mint)
|
||||
|
||||
Otwórz terminal i wklej:
|
||||
```
|
||||
curl -fsSL https://github.com/twoj-team/fxg/install.sh | bash
|
||||
```
|
||||
Gotowe.
|
||||
|
||||
### macOS
|
||||
|
||||
```
|
||||
brew install twoj-team/tap/fxg
|
||||
```
|
||||
(wymaga Homebrew — jeśli nie masz, zapytaj kolegę programistę)
|
||||
|
||||
---
|
||||
|
||||
## Krok 2 — Pierwsze uruchomienie (robisz to RAZ)
|
||||
|
||||
Otwórz terminal i wpisz:
|
||||
|
||||
```
|
||||
fxg config
|
||||
```
|
||||
|
||||
Pojawi się ekran konfiguracji. Ustaw:
|
||||
|
||||
| Pole | Co wpisać |
|
||||
|---|---|
|
||||
| `workspace_root` | Folder gdzie trzymasz projekty, np. `C:\Users\TwojeImie\projekty` lub `/home/twoje/projekty` |
|
||||
| `username` | Twoje imię i nazwisko lub nick (to będzie widoczne w commitach) |
|
||||
| `email` | Twój email z GitHuba |
|
||||
|
||||
Poruszasz się strzałkami, edytujesz pole po naciśnięciu Enter, zapisujesz przez `q` lub `Ctrl+S`.
|
||||
|
||||
---
|
||||
|
||||
## Krok 3 — Podłącz konto GitHub (robisz to RAZ)
|
||||
|
||||
Potrzebujesz **tokenu dostępu** z GitHuba. Oto jak go zdobyć:
|
||||
|
||||
1. Wejdź na [github.com/settings/tokens](https://github.com/settings/tokens)
|
||||
2. Kliknij **"Generate new token (classic)"**
|
||||
3. Wpisz nazwę (np. "fxg na moim laptopie")
|
||||
4. Zaznacz: `repo` (cały zakres)
|
||||
5. Kliknij **"Generate token"** na dole
|
||||
6. **Skopiuj token** — zobaczysz go tylko raz!
|
||||
|
||||
Teraz wróć do terminalu i wpisz (zamień `TWOJ_TOKEN` na skopiowany token):
|
||||
|
||||
```
|
||||
fxg auth add --provider github --token TWOJ_TOKEN
|
||||
```
|
||||
|
||||
Gotowe. Token jest bezpiecznie zapisany na Twoim komputerze.
|
||||
|
||||
---
|
||||
|
||||
## Codzienne użycie
|
||||
|
||||
### Sprawdź co się dzieje we wszystkich projektach
|
||||
|
||||
```
|
||||
fxg status
|
||||
```
|
||||
|
||||
Zobaczysz tabelę z projektami. Co oznaczają kolumny:
|
||||
|
||||
```
|
||||
# NAZWA BRANCH AHEAD BEHIND DIRTY OSTATNI COMMIT
|
||||
1 projekt-strona main 0 2 ✗ 2h temu
|
||||
2 projekt-logo develop 1 0 ✓ wczoraj
|
||||
```
|
||||
|
||||
| Kolumna | Co znaczy |
|
||||
|---|---|
|
||||
| `AHEAD` | Masz zmiany gotowe do wysłania na serwer |
|
||||
| `BEHIND` | Na serwerze są nowsze zmiany — ktoś coś dodał |
|
||||
| `DIRTY ✓` | Masz niezapisane zmiany (jak "niezapisany dokument") |
|
||||
|
||||
**Poruszanie się:** strzałki góra/dół, `Enter` żeby wejść w szczegóły projektu, `q` żeby wyjść.
|
||||
|
||||
---
|
||||
|
||||
### Pobierz najnowszą wersję projektu
|
||||
|
||||
Jeśli `BEHIND` pokazuje liczbę większą od 0, ktoś dodał zmiany na serwerze. Pobierz je:
|
||||
|
||||
```
|
||||
fxg sync pull --all
|
||||
```
|
||||
|
||||
To pobierze najnowszą wersję **wszystkich** projektów naraz.
|
||||
|
||||
---
|
||||
|
||||
### Wyślij swoje zmiany na serwer
|
||||
|
||||
Masz nowe pliki (np. nowe grafiki, zaktualizowane assets)? Użyj interaktywnego trybu:
|
||||
|
||||
```
|
||||
fxg sync
|
||||
```
|
||||
|
||||
Pojawi się ekran z listą projektów. Obsługa:
|
||||
|
||||
1. **Zaznacz projekt** — naciśnij `Spacja` przy projekcie który chcesz wysłać. Możesz zaznaczyć kilka.
|
||||
- `a` — zaznacz wszystkie naraz
|
||||
2. **Naciśnij Enter** — przejdziesz do wyboru akcji
|
||||
3. **Wybierz "Commit"** — strzałkami, potem Enter. Wpisz opis zmian (np. "nowe bannery dla kampanii Q2"), Enter.
|
||||
4. **Wybierz "Push"** — to faktycznie wysyła na serwer.
|
||||
|
||||
> **Commit vs Push:** Commit to jak "zapisanie wersji" lokalnie. Push to wysłanie tej wersji na serwer. Zawsze robisz najpierw Commit, potem Push.
|
||||
|
||||
---
|
||||
|
||||
### Szybki push z linii komend (jeśli wiesz co robisz)
|
||||
|
||||
```
|
||||
fxg sync push --all -m "aktualizacja grafik landing page"
|
||||
```
|
||||
|
||||
To jednocześnie commita (z wiadomością po `-m`) i pushuje wszystkie zmienione projekty.
|
||||
|
||||
---
|
||||
|
||||
## Słowniczek
|
||||
|
||||
| Słowo | Po ludzku |
|
||||
|---|---|
|
||||
| **Repozytorium** (repo) | Folder projektu na GitHubie. Jak teczka z plikami projektu. |
|
||||
| **Commit** | Zapisanie wersji plików z opisem "co zmieniłem". Jak ctrl+S z etykietką. |
|
||||
| **Push** | Wysłanie swoich zmian na serwer GitHuba. |
|
||||
| **Pull** | Pobranie zmian z serwera na swój komputer. |
|
||||
| **Branch** | Wersja projektu. `main` to główna, `develop` to robocza. |
|
||||
| **Dirty** | Pliki zmienione ale jeszcze nie scommitowane. |
|
||||
| **Merge** | Połączenie dwóch wersji projektu w jedną. Robi to zwykle programista. |
|
||||
|
||||
---
|
||||
|
||||
## Coś nie działa?
|
||||
|
||||
**Terminal nie zna komendy `fxg`**
|
||||
- Windows: sprawdź czy plik `fxg.exe` jest w folderze który jest w PATH. Najprostsze rozwiązanie: przenieś plik do `C:\Windows\System32\`
|
||||
- Linux/Mac: sprawdź czy `~/.cargo/bin` jest w PATH, lub uruchom ręcznie: `./fxg`
|
||||
|
||||
**"Permission denied" lub "access denied"**
|
||||
- Linux/Mac: wpisz `chmod +x fxg` przed pierwszym uruchomieniem
|
||||
|
||||
**"Authentication failed" przy push/pull**
|
||||
- Token prawdopodobnie wygasł lub nie ma uprawnień do `repo`
|
||||
- Wygeneruj nowy token i dodaj: `fxg auth add --provider github --token NOWY_TOKEN`
|
||||
|
||||
**Nie widzę swoich projektów w `fxg status`**
|
||||
- Sprawdź czy `workspace_root` w `fxg config` wskazuje na właściwy folder
|
||||
- Folder musi zawierać podfoldery z projektami (każdy projekt to osobny podfolder z `.git` w środku)
|
||||
|
||||
**Zmiany się nie wysyłają, `fxg sync push` nic nie robi**
|
||||
- Najpierw sprawdź `fxg status` — kolumna `DIRTY` musi być `✓` żeby był co wysyłać
|
||||
- Jeśli `DIRTY` jest `✗`, ale `AHEAD` > 0 — wystarczy sam push bez commita
|
||||
|
||||
---
|
||||
|
||||
## Szybka ściąga
|
||||
|
||||
```
|
||||
fxg status # sprawdź co się dzieje
|
||||
fxg sync pull --all # pobierz najnowsze wersje
|
||||
fxg sync # interaktywny ekran: commit + push
|
||||
fxg sync push --all -m "opis zmian" # szybki commit + push
|
||||
fxg auth add --provider github --token TOKEN # dodaj konto
|
||||
fxg config # zmień ustawienia
|
||||
```
|
||||
@@ -0,0 +1,314 @@
|
||||
# fxg — pomysły na QoL (Quality of Life)
|
||||
|
||||
> Dokument roboczy. Każdy pomysł ma tytuł, opis, przykład użycia i szacunkową trudność implementacji.
|
||||
|
||||
---
|
||||
|
||||
## Wysoki priorytet
|
||||
|
||||
### 1. `fxg clone` — pobierz wszystkie repozytoria jedną komendą
|
||||
|
||||
**Problem:** Na nowym laptopie masz 50 repo na GitHubie. Musisz je klonować jedno po drugim. Żmudne.
|
||||
|
||||
**Rozwiązanie:** Komenda pobierająca listę repo z API i klonująca je do `workspace_root`.
|
||||
|
||||
```bash
|
||||
fxg clone --mine # repo których jesteś właścicielem
|
||||
fxg clone --contributed # repo w których robiłeś commity (contributor)
|
||||
fxg clone --org mojaFirma # wszystkie repo organizacji
|
||||
fxg clone --starred # repo które oznaczyłeś gwiazdką
|
||||
|
||||
# z filtrowaniem
|
||||
fxg clone --mine --filter "frontend" # tylko repo z "frontend" w nazwie
|
||||
fxg clone --mine --limit 20 # max 20 repo
|
||||
```
|
||||
|
||||
**Implementacja:** Wywołać `provider.list_repos()` (nowa metoda w trait `Provider`), przefiltrować, wywołać `git clone` dla każdego. Wyświetlić progress w TUI podobny do `sync`.
|
||||
|
||||
**Trudność:** średnia — wymaga nowej metody w każdym providerze (GitHub/GitLab/Gitea API wszystkie to obsługują).
|
||||
|
||||
---
|
||||
|
||||
### 2. Tagi jako organizacja folderów lokalnych
|
||||
|
||||
**Problem:** Masz 50 repo i wszystkie są w jednym `workspace_root/`. Chaos.
|
||||
|
||||
**Rozwiązanie:** Tag przypisany do repo = podfolder w `workspace_root`. repo z tagiem `dev` ląduje w `workspace_root/dev/`, repo z tagiem `client-acme` ląduje w `workspace_root/client-acme/`.
|
||||
|
||||
```bash
|
||||
# przy tworzeniu repo
|
||||
fxg init moj-projekt --tags dev,frontend
|
||||
|
||||
# retroaktywnie
|
||||
fxg tag moj-projekt add client-acme
|
||||
fxg tag moj-projekt remove dev
|
||||
|
||||
# reorganizacja lokalna — przenieś foldery według tagów
|
||||
fxg workspace organize # pyta o potwierdzenie przed każdym przeniesieniem
|
||||
fxg workspace organize --dry-run # pokaż co by się przeniosło, bez wykonania
|
||||
```
|
||||
|
||||
**Wynikowa struktura:**
|
||||
```
|
||||
~/projekty/
|
||||
├── dev/
|
||||
│ ├── api-backend/
|
||||
│ └── shared-utils/
|
||||
├── client-acme/
|
||||
│ ├── acme-website/
|
||||
│ └── acme-mobile/
|
||||
└── archived/
|
||||
└── stary-projekt/
|
||||
```
|
||||
|
||||
**Implementacja:** Przechowywać tagi per-repo w `workspaces.toml` lub osobnym `tags.toml`. Komenda `organize` tworzy podfoldery i przenosi repo (lub tworzy symlinki — symlinki są bezpieczniejsze, bo oryginał zostaje w miejscu).
|
||||
|
||||
**Trudność:** średnia — logika przenoszenia + aktualizacja `workspace_root` per-repo.
|
||||
|
||||
---
|
||||
|
||||
### 3. Domyślna paczka plików przy `fxg init`
|
||||
|
||||
**Problem:** Przy tworzeniu repo trzeba pamiętać o zaznaczeniu README, CHANGELOG, .env.example w wizardzie. Zawsze się o czymś zapomni.
|
||||
|
||||
**Rozwiązanie:** Konfigurowalna lista plików tworzonych domyślnie (bez pytania), z możliwością wyłączenia.
|
||||
|
||||
```toml
|
||||
# ~/.fxg/config.toml
|
||||
default_init_files = ["README.md", "CHANGELOG.md", ".env.example", ".gitignore"]
|
||||
```
|
||||
|
||||
```bash
|
||||
fxg init moj-projekt # tworzy domyślny zestaw
|
||||
fxg init moj-projekt --no-defaults # tworzy puste repo
|
||||
fxg init moj-projekt --files readme,license # tylko te dwa
|
||||
```
|
||||
|
||||
Dodatkowo: **szablony zawartości pliku README** konfigurowalne w `config.toml` (`readme_template = "heartbyte-default"` już istnieje w konfigu, ale nie jest zaimplementowane).
|
||||
|
||||
**Trudność:** niska — już mamy `InitConfig` z polami `readme`, `changelog` itd., wystarczy ustawić domyślne wartości z configa zamiast false.
|
||||
|
||||
---
|
||||
|
||||
### 4. `fxg sync branch cleanup` — sprzątanie zmergowanych gałęzi
|
||||
|
||||
**Problem:** Po miesiącach pracy masz 30 lokalnych gałęzi, z których 25 jest już zmergowanych do main. Zajmują miejsce, mylą.
|
||||
|
||||
**Rozwiązanie:**
|
||||
```bash
|
||||
fxg sync branch cleanup # interaktywny wybór (TUI lista)
|
||||
fxg sync branch cleanup --all # usuń wszystkie zmergowane bez pytania
|
||||
fxg sync branch cleanup --dry-run # pokaż co by się usunęło
|
||||
```
|
||||
|
||||
Usuwa gałęzie które są już zmergowane do `main`/`master` — lokalnie i opcjonalnie zdalnie.
|
||||
|
||||
**Implementacja:** `git2` — `repo.branches()`, sprawdzenie czy jest zmergowana przez `repo.merge_analysis()`. Już mamy `delete_branch()` w `sync/mod.rs`.
|
||||
|
||||
**Trudność:** niska — wszystkie potrzebne funkcje `git2` już są w kodzie.
|
||||
|
||||
---
|
||||
|
||||
## Średni priorytet
|
||||
|
||||
### 5. Shell completions (bash / zsh / fish / PowerShell)
|
||||
|
||||
Wpisanie `fxg <Tab>` podpowiada:
|
||||
- subkomendy (`status`, `sync`, `init`...)
|
||||
- nazwy repo dla `fxg status <Tab>` → lista repo z workspace_root
|
||||
- nazwy workspace dla `--workspace <Tab>`
|
||||
- aliasy kont dla `fxg auth switch <Tab>`
|
||||
|
||||
```bash
|
||||
fxg completions bash >> ~/.bashrc
|
||||
fxg completions zsh >> ~/.zshrc
|
||||
fxg completions fish > ~/.config/fish/completions/fxg.fish
|
||||
fxg completions powershell >> $PROFILE
|
||||
```
|
||||
|
||||
**Implementacja:** `clap` ma wbudowany generator completion przez crate `clap_complete`. Dynamiczne uzupełnianie nazw repo wymaga osobnego skryptu który wywołuje `fxg --list-repos` (nowa ukryta flaga).
|
||||
|
||||
**Trudność:** niska (statyczne) / średnia (dynamiczne nazwy repo).
|
||||
|
||||
---
|
||||
|
||||
### 6. `fxg status --watch` — live monitoring workspace
|
||||
|
||||
```bash
|
||||
fxg status --watch # odświeża TUI co 30 sekund
|
||||
fxg status --watch --interval 10 # co 10 sekund
|
||||
```
|
||||
|
||||
TUI pokazuje kiedy ostatnio odświeżono. Przy zmianie statusu (np. nowy commit na serwerze) wiersz miga przez chwilę na żółto.
|
||||
|
||||
**Implementacja:** Tokio timer w pętli TUI, wywołuje `scan_workspace()` co N sekund. Detekcja zmian przez porównanie `ahead/behind` z poprzednim stanem.
|
||||
|
||||
**Trudność:** niska — już mamy `scan_workspace()` i pętlę TUI.
|
||||
|
||||
---
|
||||
|
||||
### 7. Szablony projektów (`fxg init --template`)
|
||||
|
||||
```bash
|
||||
fxg init moj-projekt --template react-app
|
||||
fxg init moj-projekt --template rust-cli
|
||||
fxg init moj-projekt --template python-fastapi
|
||||
fxg init moj-projekt --template node-express
|
||||
```
|
||||
|
||||
Każdy szablon tworzy kompletną strukturę katalogów i pliki startowe:
|
||||
- `react-app` → `src/`, `public/`, `package.json` z React, `.eslintrc`, Dockerfile
|
||||
- `rust-cli` → `src/main.rs`, `Cargo.toml` z clap+anyhow, `README.md`
|
||||
- `python-fastapi` → `main.py`, `requirements.txt`, `.env.example`, `Dockerfile`
|
||||
|
||||
Szablony przechowywane w `~/.fxg/templates/` jako foldery — użytkownik może dodać własne.
|
||||
|
||||
**Trudność:** średnia — logika tworzenia plików jest już w `init/mod.rs`, potrzebny nowy system szablonów.
|
||||
|
||||
---
|
||||
|
||||
### 8. `fxg pr list` — przegląd otwartych PR
|
||||
|
||||
```bash
|
||||
fxg pr list # wszystkie PR we wszystkich repo workspace
|
||||
fxg pr list --repo moj-projekt # tylko jedno repo
|
||||
fxg pr list --mine # tylko PR gdzie jesteś autorem lub reviewerem
|
||||
```
|
||||
|
||||
Wyświetla TUI tabelę: repo | numer | tytuł | autor | data | etykiety.
|
||||
|
||||
Enter na PR otwiera URL w przeglądarce.
|
||||
|
||||
**Implementacja:** Nowa metoda `list_pull_requests()` w trait `Provider`, wywołanie API. GitHub/GitLab/Gitea wszystkie to mają.
|
||||
|
||||
**Trudność:** średnia — nowa metoda w każdym providerze.
|
||||
|
||||
---
|
||||
|
||||
### 9. Globalny `.fxgignore` — repo do pominięcia
|
||||
|
||||
```
|
||||
# ~/.fxg/ignore
|
||||
archived-* # wszystkie repo zaczynające się od "archived-"
|
||||
old-monorepo # konkretne repo po nazwie
|
||||
```
|
||||
|
||||
`scan_workspace()` pomija pasujące foldery. Przydatne gdy `workspace_root` zawiera też foldery inne niż repo git.
|
||||
|
||||
**Implementacja:** Dodać `ignore_patterns: Vec<String>` do `AppConfig`, sprawdzać przy `scan_workspace()`.
|
||||
|
||||
**Trudność:** niska.
|
||||
|
||||
---
|
||||
|
||||
### 10. `fxg stats --trend` — wykres aktywności w czasie
|
||||
|
||||
```bash
|
||||
fxg stats --trend # ostatnie 30 dni
|
||||
fxg stats --trend --days 90
|
||||
fxg stats --trend --repo api-backend
|
||||
```
|
||||
|
||||
ASCII wykres słupkowy commitów per dzień:
|
||||
|
||||
```
|
||||
Aktywność (ostatnie 30 dni) — api-backend
|
||||
|
||||
May 01 ████████░░░░░░░░░░░░░ 8
|
||||
May 08 ████████████░░░░░░░░░ 12
|
||||
May 15 ██████░░░░░░░░░░░░░░░ 6
|
||||
May 22 ██████████████████░░░ 18
|
||||
```
|
||||
|
||||
**Implementacja:** `revwalk` po historii commitów + grupowanie po dniach. Już mamy `git2` w projekcie.
|
||||
|
||||
**Trudność:** niska.
|
||||
|
||||
---
|
||||
|
||||
## Przyszłość / ambitne
|
||||
|
||||
### 11. VSCode Extension — automatyczne time tracking
|
||||
|
||||
Aktualnie `fxg stats time` czyta pliki JSON z `~/.fxg/vsc-data/`. Trzeba je tworzyć ręcznie lub osobnym narzędziem.
|
||||
|
||||
**Rozwiązanie:** Towarzyszące rozszerzenie VS Code które automatycznie zapisuje czas spędzony na plikach w formacie rozumianym przez `fxg`.
|
||||
|
||||
```
|
||||
fxg stats time # pokazuje automatycznie zbierane dane
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12. Webhook przy każdym sync
|
||||
|
||||
Opcjonalny URL w `config.toml`:
|
||||
```toml
|
||||
sync_webhook = "https://discord.com/api/webhooks/..."
|
||||
```
|
||||
|
||||
Po każdym udanym `push` wysyła embed z: repo, branch, commit message, autor.
|
||||
Identyczny mechanizm jak audit log w innych projektach z tego zespołu.
|
||||
|
||||
**Trudność:** niska — prosty `reqwest::post` po każdym `push_repo()`.
|
||||
|
||||
---
|
||||
|
||||
### 13. AI-assisted commit messages
|
||||
|
||||
```bash
|
||||
fxg sync commit --ai # generuje wiadomość z diff za pomocą AI API
|
||||
fxg sync commit --ai --edit # generuje, ale otwiera edytor do poprawki przed commitem
|
||||
```
|
||||
|
||||
Wysyła `git diff --staged` do API (np. Claude lub OpenAI), dostaje propozycję wiadomości w stylu conventional commits.
|
||||
|
||||
Wymaga klucza API w konfiguracji.
|
||||
|
||||
---
|
||||
|
||||
### 14. `fxg release` multi-platform publish
|
||||
|
||||
Po stworzeniu release tagu — automatyczne wyzwolenie publishu na:
|
||||
- `crates.io` (dla Rust)
|
||||
- `npm` (dla Node.js)
|
||||
- `PyPI` (dla Python)
|
||||
|
||||
Wykrywa typ projektu przez `detect_project_type()` (już istnieje w `summary/mod.rs`).
|
||||
|
||||
---
|
||||
|
||||
### 15. Interaktywny diff viewer
|
||||
|
||||
W widoku szczegółowym repo (`fxg status <repo>`) — klawisz `d` otwiera inlinowy diff zmienionych plików z kolorowym podświetleniem:
|
||||
|
||||
```
|
||||
M src/components/Nav.tsx [d] diff [s] stage [r] revert
|
||||
─────────────────────────────────────────
|
||||
- const navItems = ["Home", "About"];
|
||||
+ const navItems = ["Home", "About", "Contact"];
|
||||
```
|
||||
|
||||
**Trudność:** wysoka — wymaga parsowania diff output i renderowania w ratatui.
|
||||
|
||||
---
|
||||
|
||||
## Szybkie podsumowanie priorytetów
|
||||
|
||||
| # | Pomysł | Priorytet | Trudność |
|
||||
|---|---|---|---|
|
||||
| 1 | `fxg clone --mine/--contributed` | Wysoki | Średnia |
|
||||
| 2 | Tagi → organizacja folderów | Wysoki | Średnia |
|
||||
| 3 | Domyślna paczka plików przy init | Wysoki | Niska |
|
||||
| 4 | `branch cleanup` | Wysoki | Niska |
|
||||
| 5 | Shell completions | Średni | Niska–Średnia |
|
||||
| 6 | `status --watch` | Średni | Niska |
|
||||
| 7 | Szablony projektów | Średni | Średnia |
|
||||
| 8 | `pr list` | Średni | Średnia |
|
||||
| 9 | `.fxgignore` | Średni | Niska |
|
||||
| 10 | `stats --trend` | Średni | Niska |
|
||||
| 11 | VSCode extension | Przyszłość | Wysoka |
|
||||
| 12 | Webhook przy sync | Przyszłość | Niska |
|
||||
| 13 | AI commit messages | Przyszłość | Średnia |
|
||||
| 14 | Multi-platform publish | Przyszłość | Wysoka |
|
||||
| 15 | Diff viewer w TUI | Przyszłość | Wysoka |
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in new issue
Block a user