use clap::{Parser, Subcommand};
use anyhow::Result;
use std::path::PathBuf;
use dirs;
use crate::config::ToriiConfig;
use crate::core::GitRepo;
use crate::remote::{get_platform_client, Visibility, RepoSettings, RepoFeatures};
use crate::snapshot::SnapshotManager;
use crate::mirror::{MirrorManager, AccountType, Protocol};
use crate::ssh::SshHelper;
use crate::duration::parse_duration;
use crate::versioning::AutoTagger;
use crate::scanner;
use crate::issue::{get_issue_client, CreateIssueOptions};
use crate::pr::detect_platform_from_remote;
fn parse_account_type(s: &str) -> Result<AccountType> {
match s.to_lowercase().as_str() {
"user" | "u" => Ok(AccountType::User),
"org" | "organization" | "o" => Ok(AccountType::Organization),
_ => Err(anyhow::anyhow!("Invalid account type. Use 'user' or 'org'")),
}
}
fn parse_protocol(s: Option<&String>) -> Protocol {
match s.map(|s| s.to_lowercase()) {
Some(p) if p == "https" || p == "http" => Protocol::HTTPS,
Some(p) if p == "ssh" => Protocol::SSH,
None => {
if SshHelper::has_ssh_keys() {
Protocol::SSH
} else {
println!("⚠️ No SSH keys detected. Using HTTPS protocol.");
println!(" Run 'torii config check-ssh' for SSH setup instructions.\n");
Protocol::HTTPS
}
}
_ => Protocol::SSH,
}
}
#[derive(Parser)]
#[command(name = "torii")]
#[command(version, about = "A modern git client with simplified commands")]
#[command(after_help = "Examples:
torii init Initialize a new repo
torii save -am \"feat: add login\" Stage all and commit
torii sync Pull and push
torii sync main Integrate main into current branch
torii branch feature/auth -c Create and switch to branch
torii clone github user/repo Clone from GitHub
torii log --oneline --graph Show compact history graph
torii snapshot stash Stash work in progress
torii mirror sync Push to all configured mirrors
Run 'torii <command> --help' for detailed usage of any command.")]
pub struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(after_help = "Examples:
torii init Initialize in current directory
torii init --path ~/projects/myrepo Initialize in specific path")]
Init {
#[arg(short, long)]
path: Option<String>,
},
#[command(after_help = "Examples:
torii save -m \"fix: null check\" Commit staged changes
torii save -am \"feat: add login\" Stage all and commit
torii save src/auth.rs -m \"fix: token\" Stage specific file and commit
torii save --amend -m \"fix: typo\" Amend last commit message
torii save --revert abc1234 -m \"revert\" Revert a specific commit
torii save --reset HEAD~1 --reset-mode soft Undo last commit, keep changes
torii save --unstage src/secret.rs Remove a path from the index
torii save --unstage --all Unstage everything")]
Save {
#[arg(short, long, required_unless_present_any = ["reset", "revert", "unstage"])]
message: Option<String>,
#[arg(short, long)]
all: bool,
#[arg(value_name = "FILES")]
files: Vec<PathBuf>,
#[arg(long)]
amend: bool,
#[arg(long, value_name = "HASH")]
revert: Option<String>,
#[arg(long, value_name = "HASH")]
reset: Option<String>,
#[arg(long, default_value = "mixed", verbatim_doc_comment)]
reset_mode: String,
#[arg(long, conflicts_with_all = ["amend", "revert", "reset"])]
unstage: bool,
},
#[command(after_help = "Examples:
torii sync Pull from remote then push
torii sync --pull Pull only
torii sync --push Push only
torii sync --force Force push (rewrites remote history)
torii sync --fetch Fetch remote refs without merging
torii sync main Integrate main into current branch (smart merge/rebase)
torii sync main --merge Force merge strategy
torii sync main --rebase Force rebase strategy
torii sync main --preview Preview what would happen without executing")]
Sync {
branch: Option<String>,
#[arg(short, long)]
pull: bool,
#[arg(short = 'P', long)]
push: bool,
#[arg(short, long)]
force: bool,
#[arg(long)]
fetch: bool,
#[arg(long)]
merge: bool,
#[arg(long)]
rebase: bool,
#[arg(long)]
preview: bool,
#[arg(long)]
verify: bool,
},
#[command(after_help = "Examples:
torii status Show staged, unstaged, and untracked files")]
Status,
#[command(after_help = "Examples:
torii log Last 10 commits
torii log -n 50 Last 50 commits
torii log --oneline One line per commit
torii log --graph Branch graph
torii log --oneline --graph Compact graph view
torii log --author \"Alice\" Filter by author
torii log --since 2024-01-01 Commits after date
torii log --until 2024-12-31 Commits before date
torii log --grep \"feat\" Filter by message pattern
torii log --stat Show file change stats per commit")]
Log {
#[arg(short = 'n', long)]
count: Option<usize>,
#[arg(long)]
oneline: bool,
#[arg(long)]
graph: bool,
#[arg(long)]
author: Option<String>,
#[arg(long)]
since: Option<String>,
#[arg(long)]
until: Option<String>,
#[arg(long)]
grep: Option<String>,
#[arg(long)]
stat: bool,
#[arg(long)]
reflog: bool,
},
#[command(after_help = "Examples:
torii diff Show unstaged changes
torii diff --staged Show staged changes (ready to commit)
torii diff --last Show changes in last commit")]
Diff {
#[arg(long)]
staged: bool,
#[arg(long)]
last: bool,
},
#[command(after_help = "Examples:
torii blame src/main.rs Annotate every line
torii blame src/main.rs -L 10,20 Limit to lines 10-20")]
Blame {
file: String,
#[arg(short = 'L', long)]
lines: Option<String>,
},
#[command(after_help = "Examples:
torii scan Scan staged files (used automatically by 'save')
torii scan --history Scan the entire git history")]
Scan {
#[arg(long)]
history: bool,
},
#[command(name = "cherry-pick", after_help = "Examples:
torii cherry-pick abc1234 Apply a commit
torii cherry-pick --continue Resume after resolving conflicts
torii cherry-pick --abort Abort an in-progress cherry-pick")]
CherryPick {
commit: Option<String>,
#[arg(long)]
r#continue: bool,
#[arg(long)]
abort: bool,
},
#[command(after_help = "Examples:
torii branch List local branches
torii branch --all List local and remote branches
torii branch feature/auth -c Create and switch to branch
torii branch gh-pages -c --orphan Create orphan branch (no history)
torii branch main Switch to existing branch
torii branch -d feature/auth Delete local branch
torii branch -d feature/auth --force Force delete (not merged)
torii branch --delete-remote feature/auth Delete branch on all remotes
torii branch --rename new-name Rename current branch")]
Branch {
name: Option<String>,
#[arg(short, long)]
create: bool,
#[arg(long)]
orphan: bool,
#[arg(short, long)]
delete: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
delete_remote: Option<String>,
#[arg(short, long)]
list: bool,
#[arg(short, long)]
rename: Option<String>,
#[arg(short, long)]
all: bool,
},
#[command(after_help = "Examples:
torii clone github user/repo Clone from GitHub (auto SSH/HTTPS)
torii clone gitlab user/repo Clone from GitLab
torii clone github user/repo --protocol https Force HTTPS
torii clone github user/repo -d my-dir Clone into specific directory
torii clone https://github.com/user/repo.git Clone from full URL
torii clone git@github.com:user/repo.git Clone via SSH URL
Supported platforms: github, gitlab, codeberg, bitbucket, gitea, forgejo
Protocol is auto-detected: SSH if keys are configured, HTTPS otherwise.
Override with --protocol or set default: torii config set mirror.default_protocol https")]
Clone {
source: String,
args: Vec<String>,
#[arg(short = 'd', long)]
directory: Option<String>,
#[arg(long)]
protocol: Option<String>,
},
#[command(after_help = "Examples:
torii tag list List all tags
torii tag create v1.2.0 -m \"Release\" Create annotated tag
torii tag delete v1.0.0 Delete a tag
torii tag push v1.2.0 Push specific tag to remote
torii tag push Push all tags to remote
torii tag show v1.2.0 Show tag details
torii tag release Auto-bump version from conventional commits
torii tag release --bump minor Force minor bump
torii tag release --dry-run Preview without creating tag
Auto-bump rules (Conventional Commits):
feat: → minor bump (0.1.0 → 0.2.0)
fix: / perf: → patch bump (0.1.0 → 0.1.1)
feat!: → major bump (0.1.0 → 1.0.0)")]
Tag {
#[command(subcommand)]
action: TagCommands,
},
#[command(after_help = "Examples:
torii snapshot create -n \"before-refactor\" Create named snapshot
torii snapshot list List all snapshots
torii snapshot restore <id> Restore a snapshot
torii snapshot delete <id> Delete a snapshot
torii snapshot stash Stash current work
torii snapshot stash -u Stash including untracked files
torii snapshot unstash Restore latest stash
torii snapshot unstash <id> --keep Restore stash but keep it
torii snapshot undo Undo last operation")]
Snapshot {
#[command(subcommand)]
action: SnapshotCommands,
},
#[command(after_help = "Examples:
torii mirror add gitlab user paskidev myrepo --primary Set GitLab as primary (source of truth)
torii mirror add github user paskidev myrepo Add GitHub as a replica mirror
torii mirror promote github paskidev Promote a mirror to primary
torii mirror sync Push to all replica mirrors
torii mirror sync --force Force push to all mirrors
torii mirror list List configured mirrors
torii mirror remove github paskidev Remove a mirror
torii mirror autofetch --enable --interval 30m Auto-fetch every 30 min
torii mirror autofetch --disable Disable auto-fetch
torii mirror autofetch --status Show autofetch status
Supported platforms: github, gitlab, codeberg, bitbucket, gitea, forgejo")]
Mirror {
#[command(subcommand)]
action: MirrorCommands,
},
#[command(after_help = "Examples:
torii show Show HEAD commit with diff
torii show abc1234 Show specific commit
torii show v1.0.0 Show tag details
torii show src/main.rs --blame Show line-by-line change history
torii show src/main.rs --blame -L 10,20 Blame specific line range")]
Show {
object: Option<String>,
#[arg(long)]
blame: bool,
#[arg(short = 'L', long, requires = "blame")]
lines: Option<String>,
},
#[command(after_help = "Examples:
torii history reflog Show HEAD movement history
torii history rebase main Rebase current branch onto main
torii history rebase -i HEAD~5 Interactive rebase last 5 commits
torii history rebase --continue Continue after resolving conflicts
torii history rebase --abort Abort current rebase
torii history cherry-pick abc1234 Apply a commit to current branch
torii history blame src/main.rs Line-by-line change history
torii history blame src/main.rs -L 10,20 Specific line range
torii history scan Scan staged files for secrets
torii history scan --history Scan entire git history for secrets
torii history remove-file secrets.txt Purge file from all commits
torii history rewrite \"2024-01-01\" \"2024-12-31\" Rewrite commit dates
torii history clean GC and expire reflog")]
History {
#[command(subcommand)]
action: HistoryCommands,
},
#[command(after_help = "Examples:
torii config list Show all config values
torii config list --local Show local repo config
torii config get user.name Get a value
torii config set user.name \"Alice\" Set a global value
torii config set user.email \"a@b.com\" --local Set a local value
torii config set auth.github_token ghp_xxx Set GitHub token
torii config set auth.gitlab_token glpat-xxx Set GitLab token
torii config set mirror.default_protocol https Use HTTPS by default
torii config edit Open config in editor
torii config reset Reset to defaults
Available keys:
user.name, user.email, user.editor
auth.github_token, auth.gitlab_token, auth.gitea_token
auth.forgejo_token, auth.codeberg_token
git.default_branch, git.sign_commits, git.pull_rebase
mirror.default_protocol, mirror.autofetch_enabled
snapshot.auto_enabled, snapshot.auto_interval_minutes
ui.colors, ui.emoji, ui.verbose, ui.date_format")]
Config {
#[command(subcommand)]
action: ConfigCommands,
},
#[command(after_help = "Examples:
torii remote create github myrepo --public Create public repo on GitHub
torii remote create gitlab myrepo --private Create private repo on GitLab
torii remote create github myrepo --private --push Create and push current branch
torii remote delete github owner myrepo --yes Delete repo (no confirmation)
torii remote visibility github owner myrepo --public Make repo public
torii remote visibility github owner myrepo --private Make repo private
torii remote configure github owner myrepo --default-branch main
torii remote info github owner myrepo Show repo details
torii remote list github List all your GitHub repos
Supported platforms: github, gitlab, codeberg, bitbucket, gitea, forgejo")]
Remote {
#[command(subcommand)]
action: RemoteCommands,
},
#[command(after_help = "Examples:
torii workspace add work ~/repos/api Add repo to workspace
torii workspace list List all workspaces
torii workspace status work Show status of all repos
torii workspace save work -m \"wip\" Commit across all repos
torii workspace sync work Pull+push all repos")]
Workspace {
#[command(subcommand)]
action: WorkspaceCommands,
},
#[command(after_help = "Examples:
torii pr list List open PRs
torii pr list --state closed List closed PRs
torii pr create -t \"feat: login\" -b main
torii pr merge 42 Merge PR #42
torii pr merge 42 --method squash Squash merge
torii pr close 42 Close PR #42
torii pr checkout 42 Checkout PR branch
torii pr open 42 Open PR in browser")]
Pr {
#[command(subcommand)]
action: PrCommands,
},
#[command(after_help = "Examples:
torii issue list List open issues
torii issue list --state closed List closed issues
torii issue create -t \"bug: crash\" Create issue
torii issue create -t \"title\" -d \"desc\" Create with description
torii issue close 42 Close issue #42
torii issue comment 42 -m \"Fixed in v2\" Add a comment")]
Issue {
#[command(subcommand)]
action: IssueCommands,
},
#[command(after_help = "Examples:
torii tui Open dashboard (status, log, file navigation)")]
Tui,
}
#[derive(Subcommand)]
enum PrCommands {
List {
#[arg(long, default_value = "open")]
state: String,
},
Create {
#[arg(short, long)]
title: String,
#[arg(short, long, default_value = "main")]
base: String,
#[arg(long)]
head: Option<String>,
#[arg(short, long)]
description: Option<String>,
#[arg(long)]
draft: bool,
},
Merge {
number: u64,
#[arg(long, default_value = "merge")]
method: String,
},
Close {
number: u64,
},
Checkout {
number: u64,
},
Open {
number: u64,
},
}
#[derive(Subcommand)]
enum IssueCommands {
List {
#[arg(long, default_value = "open")]
state: String,
},
Create {
#[arg(short, long)]
title: String,
#[arg(short = 'd', long)]
description: Option<String>,
},
Close {
number: u64,
},
Comment {
number: u64,
#[arg(short, long)]
message: String,
},
}
#[derive(Subcommand)]
enum WorkspaceCommands {
Add {
workspace: String,
path: String,
},
Remove {
workspace: String,
path: String,
},
Delete {
workspace: String,
},
List,
Status {
workspace: String,
},
Save {
workspace: String,
#[arg(short, long)]
message: String,
#[arg(short, long)]
all: bool,
},
Sync {
workspace: String,
#[arg(long)]
force: bool,
},
}
#[derive(Subcommand)]
enum ConfigCommands {
Set {
key: String,
value: String,
#[arg(long)]
local: bool,
},
Get {
key: String,
#[arg(long)]
local: bool,
},
List {
#[arg(long)]
local: bool,
},
Edit {
#[arg(long)]
local: bool,
},
Reset {
#[arg(long)]
local: bool,
},
#[command(name = "check-ssh")]
CheckSsh,
}
#[derive(Subcommand)]
enum RemoteCommands {
#[command(after_help = "Examples:
torii remote create github myrepo One platform
torii remote create github,gitlab,codeberg myrepo Multiple platforms (comma-separated)
torii remote create github myrepo --private --push Create + link origin + push")]
Create {
#[arg(value_delimiter = ',')]
platforms: String,
name: String,
#[arg(short, long)]
description: Option<String>,
#[arg(long)]
public: bool,
#[arg(long)]
private: bool,
#[arg(long)]
push: bool,
},
Delete {
platforms: String,
owner: String,
repo: String,
#[arg(short = 'y', long)]
yes: bool,
},
Visibility {
platform: String,
owner: String,
repo: String,
#[arg(long, conflicts_with = "private")]
public: bool,
#[arg(long, conflicts_with = "public")]
private: bool,
},
Configure {
platform: String,
owner: String,
repo: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
homepage: Option<String>,
#[arg(long)]
default_branch: Option<String>,
#[arg(long)]
enable_issues: bool,
#[arg(long, conflicts_with = "enable_issues")]
disable_issues: bool,
#[arg(long)]
enable_wiki: bool,
#[arg(long, conflicts_with = "enable_wiki")]
disable_wiki: bool,
#[arg(long)]
enable_projects: bool,
#[arg(long, conflicts_with = "enable_projects")]
disable_projects: bool,
},
Info {
platform: String,
owner: String,
repo: String,
},
List {
platform: String,
},
Local,
#[command(after_help = "Examples:
torii remote link github user/repo Link via SSH (default)
torii remote link gitlab user/repo --https Link via HTTPS
torii remote link --url git@host:owner/repo.git
torii remote link my-fork github user/repo Use a remote name other than 'origin'")]
Link {
#[arg(long, default_value = "origin")]
name: String,
platform: Option<String>,
repo: Option<String>,
#[arg(long)]
https: bool,
#[arg(long, value_name = "URL")]
url: Option<String>,
#[arg(long)]
force: bool,
},
}
#[derive(Subcommand)]
enum HistoryCommands {
Rewrite {
start: String,
end: String,
},
Clean,
RemoveFile {
file: String,
},
Rebase {
target: Option<String>,
#[arg(short, long)]
interactive: bool,
#[arg(long, value_name = "FILE")]
todo_file: Option<PathBuf>,
#[arg(long)]
root: bool,
#[arg(long)]
r#continue: bool,
#[arg(long)]
abort: bool,
#[arg(long)]
skip: bool,
},
}
#[derive(Subcommand)]
enum SnapshotCommands {
Create {
#[arg(short, long)]
name: Option<String>,
},
List,
Restore {
id: String,
},
Delete {
id: String,
},
Config {
#[arg(long)]
enable: bool,
#[arg(long)]
interval: Option<String>,
},
Stash {
#[arg(short, long)]
name: Option<String>,
#[arg(short = 'u', long)]
include_untracked: bool,
},
Unstash {
id: Option<String>,
#[arg(short, long)]
keep: bool,
},
Undo,
}
#[derive(Debug, Subcommand)]
enum TagCommands {
Create {
name: Option<String>,
#[arg(short, long)]
message: Option<String>,
#[arg(long)]
release: bool,
#[arg(long, requires = "release")]
bump: Option<String>,
#[arg(long, requires = "release")]
dry_run: bool,
},
List,
Delete {
name: String,
},
Push {
name: Option<String>,
},
Show {
name: String,
},
}
#[derive(Subcommand)]
enum MirrorCommands {
Add {
platform: String,
account_type: String,
account: String,
repo: String,
#[arg(long)]
primary: bool,
#[arg(short, long)]
protocol: Option<String>,
},
List,
Sync {
#[arg(short, long)]
force: bool,
},
Promote {
platform: String,
account: String,
},
Remove {
platform: String,
account: String,
},
Autofetch {
#[arg(long)]
enable: bool,
#[arg(long, conflicts_with = "enable")]
disable: bool,
#[arg(long)]
interval: Option<String>,
#[arg(long, conflicts_with_all = ["enable", "disable", "interval"])]
status: bool,
},
}
impl Cli {
pub fn execute(&self) -> Result<()> {
match &self.command {
Commands::Init { path } => {
let repo_path = path.as_deref().unwrap_or(".");
GitRepo::init(repo_path)?;
let toriignore_path = std::path::Path::new(repo_path).join(".toriignore");
if !toriignore_path.exists() {
std::fs::write(&toriignore_path, crate::toriignore::ToriIgnore::default_content())
.ok();
}
let repo = GitRepo::open(repo_path)?;
repo.sync_toriignore()?;
println!("✅ Initialized repository at {}", repo_path);
println!(" Created .toriignore with default patterns");
}
Commands::Save { message, all, files, amend, revert, reset, reset_mode, unstage } => {
let repo = GitRepo::open(".")?;
if *unstage {
if *all {
if !files.is_empty() {
anyhow::bail!("Pass either --all or specific paths, not both");
}
repo.unstage_all()?;
println!("✅ Unstaged all paths");
} else {
if files.is_empty() {
anyhow::bail!("Provide at least one path or use --all");
}
repo.unstage(files)?;
println!("✅ Unstaged {} path(s)", files.len());
}
return Ok(());
}
if let Some(commit_hash) = reset {
repo.reset_commit(commit_hash, reset_mode)?;
println!("✅ Reset to commit: {} (mode: {})", commit_hash, reset_mode);
} else if let Some(commit_hash) = revert {
repo.revert_commit(commit_hash)?;
println!("✅ Reverted commit: {}", commit_hash);
} else {
if *all && !files.is_empty() {
anyhow::bail!("Cannot use --all and specific files at the same time");
}
if *all {
repo.add_all()?;
} else if !files.is_empty() {
repo.add(files)?;
}
let repo_path = std::path::Path::new(".");
let findings = scanner::scan_staged(repo_path)?;
if !findings.is_empty() {
println!("⚠️ Sensitive data detected in staged files:\n");
for f in &findings {
if f.line == 0 {
println!(" {} — {}", f.file, f.pattern_name);
} else {
println!(" {}:{} — {}", f.file, f.line, f.pattern_name);
}
println!(" {}\n", f.preview);
}
println!("💡 Tip: use .env.example for placeholder values — those files are always safe to commit.");
print!(" Continue anyway? [y/N] ");
use std::io::Write;
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("❌ Commit cancelled.");
return Ok(());
}
}
let msg = message.as_deref().ok_or_else(|| anyhow::anyhow!(
"--message/-m is required for commit/amend"
))?;
if *amend {
repo.commit_amend(msg)?;
println!("✅ Commit amended: {}", msg);
} else {
repo.commit(msg)?;
println!("✅ Changes saved: {}", msg);
}
}
}
Commands::Sync { branch, pull, push, force, fetch, merge, rebase, preview, verify } => {
let repo = GitRepo::open(".")?;
if *verify {
repo.verify_remote()?;
return Ok(());
}
if let Some(branch_name) = branch {
if *preview {
println!("🔍 Preview: Would integrate branch '{}'", branch_name);
println!("💡 Recommendation: Use merge for feature branches, rebase for clean history");
} else if *merge {
println!("🔀 Merging branch '{}'...", branch_name);
repo.merge_branch(branch_name)?;
println!("✅ Merged branch: {}", branch_name);
} else if *rebase {
println!("🔄 Rebasing onto branch '{}'...", branch_name);
repo.rebase_branch(branch_name)?;
println!("✅ Rebased onto: {}", branch_name);
} else {
println!("🔀 Integrating branch '{}'...", branch_name);
repo.merge_branch(branch_name)?;
println!("✅ Integrated branch: {}", branch_name);
}
} else if *fetch {
repo.fetch()?;
println!("✅ Fetched from remote");
} else if *force {
repo.push(true)?;
println!("✅ Force synced with remote");
let mirror_mgr = MirrorManager::new(".")?;
mirror_mgr.sync_replicas_if_any(true)?;
} else if *pull {
repo.pull()?;
println!("✅ Pulled from remote");
} else if *push {
repo.push(false)?;
println!("✅ Pushed to remote");
let mirror_mgr = MirrorManager::new(".")?;
mirror_mgr.sync_replicas_if_any(false)?;
} else {
repo.pull()?;
repo.push(false)?;
println!("✅ Synced with remote");
let mirror_mgr = MirrorManager::new(".")?;
mirror_mgr.sync_replicas_if_any(false)?;
}
}
Commands::Status => {
let repo = GitRepo::open(".")?;
repo.status()?;
}
Commands::Log { count, oneline, graph, author, since, until, grep, stat, reflog } => {
let repo = GitRepo::open(".")?;
if *reflog {
repo.show_reflog(count.unwrap_or(20))?;
} else {
repo.log(*count, *oneline, *graph, author.as_deref(), since.as_deref(), until.as_deref(), grep.as_deref(), *stat)?;
}
}
Commands::Diff { staged, last } => {
let repo = GitRepo::open(".")?;
repo.diff(*staged, *last)?;
}
Commands::Blame { file, lines } => {
let repo = GitRepo::open(".")?;
repo.blame(file, lines.as_deref())?;
}
Commands::Scan { history } => {
run_scan(*history)?;
}
Commands::CherryPick { commit, r#continue, abort } => {
let repo = GitRepo::open(".")?;
if *r#continue {
repo.cherry_pick_continue()?;
} else if *abort {
repo.cherry_pick_abort()?;
} else {
let hash = commit.as_deref().ok_or_else(|| anyhow::anyhow!("Commit hash required: torii cherry-pick <hash>"))?;
repo.cherry_pick(hash)?;
}
}
Commands::Branch { name, create, orphan, delete, force, delete_remote, list, rename, all } => {
let repo = GitRepo::open(".")?;
if *list || *all {
let branches = repo.list_branches()?;
println!("📋 Branches:");
for branch in branches {
println!(" • {}", branch);
}
if *all {
let remote_branches = repo.list_remote_branches()?;
println!("\n📡 Remote branches:");
if remote_branches.is_empty() {
println!(" (none — run 'torii sync --fetch' to update remote refs)");
} else {
for branch in remote_branches {
println!(" • {}", branch);
}
}
}
} else if let Some(branch_name) = delete_remote {
let git_repo = git2::Repository::discover(".")?;
let remotes = git_repo.remotes()?;
let mut deleted = vec![];
let mut errors = vec![];
for remote_name in remotes.iter().flatten() {
let result = std::process::Command::new("git")
.args(["push", remote_name, "--delete", branch_name])
.output();
match result {
Ok(o) if o.status.success() => deleted.push(remote_name.to_string()),
Ok(o) => errors.push(format!("{}: {}", remote_name, String::from_utf8_lossy(&o.stderr).trim().to_string())),
Err(e) => errors.push(format!("{}: {}", remote_name, e)),
}
}
if !deleted.is_empty() {
println!("✅ Deleted '{}' on: {}", branch_name, deleted.join(", "));
}
if !errors.is_empty() {
for e in &errors { eprintln!("⚠️ {}", e); }
}
if deleted.is_empty() {
anyhow::bail!("Could not delete '{}' on any remote", branch_name);
}
} else if let Some(branch_name) = delete {
if *force {
let git_repo = git2::Repository::discover(".")?;
let mut branch = git_repo.find_branch(branch_name, git2::BranchType::Local)?;
branch.delete()?;
} else {
repo.delete_branch(branch_name)?;
}
println!("✅ Deleted branch: {}", branch_name);
} else if let Some(new_name) = rename {
let current = repo.get_current_branch()?;
repo.rename_branch(¤t, new_name)?;
println!("✅ Renamed branch {} to {}", current, new_name);
} else if let Some(branch_name) = name {
if *orphan && !*create {
anyhow::bail!("--orphan requires -c/--create");
}
if *create && *orphan {
repo.create_orphan_branch(branch_name)?;
println!("✅ Created orphan branch: {} (no parents — first commit will be a new root)", branch_name);
} else if *create {
repo.create_branch(branch_name)?;
repo.switch_branch(branch_name)?;
println!("✅ Created and switched to branch: {}", branch_name);
} else {
repo.switch_branch(branch_name)?;
println!("✅ Switched to branch: {}", branch_name);
}
} else {
let branches = repo.list_branches()?;
println!("📋 Branches:");
for branch in branches {
println!(" • {}", branch);
}
}
}
Commands::Clone { source, args, directory, protocol } => {
let url = if !args.is_empty() {
let platform = source;
let user_repo = &args[0];
let use_ssh = match protocol.as_deref() {
Some("https") | Some("http") => false,
Some("ssh") => true,
_ => {
let cfg = ToriiConfig::load_global().unwrap_or_default();
if cfg.mirror.default_protocol == "https" {
false
} else {
SshHelper::has_ssh_keys()
}
}
};
let (ssh_host, https_host) = match platform.as_str() {
"github" => ("github.com", "github.com"),
"gitlab" => ("gitlab.com", "gitlab.com"),
"codeberg" => ("codeberg.org", "codeberg.org"),
"bitbucket" => ("bitbucket.org", "bitbucket.org"),
"gitea" => ("gitea.com", "gitea.com"),
"forgejo" => ("codeberg.org", "codeberg.org"),
_ => anyhow::bail!(
"Unknown platform '{}'. Supported: github, gitlab, codeberg, bitbucket, gitea, forgejo",
platform
),
};
if use_ssh {
format!("git@{}:{}.git", ssh_host, user_repo)
} else {
format!("https://{}/{}.git", https_host, user_repo)
}
} else if source.starts_with("http") || source.starts_with("git@") {
source.clone()
} else {
anyhow::bail!(
"Usage:\n torii clone <platform> <user/repo> e.g. torii clone github user/repo\n torii clone <platform> <user/repo> --protocol https\n torii clone <url> e.g. torii clone https://github.com/user/repo.git"
)
};
let target_dir = directory.as_deref();
GitRepo::clone_repo(&url, target_dir)?;
let dir_name = target_dir.unwrap_or_else(|| {
url.split('/').last().unwrap_or("repo").trim_end_matches(".git")
});
println!("✅ Cloned repository to: {}", dir_name);
}
Commands::Tag { action } => {
let repo = GitRepo::open(".")?;
match action {
TagCommands::Create { name, message, release, bump, dry_run } => {
if *release {
let tagger = AutoTagger::new(repo);
let current = tagger.get_latest_version()?;
let next = if let Some(bump_str) = bump {
use crate::versioning::semver::VersionBump;
let b = match bump_str.as_str() {
"major" => VersionBump::Major,
"minor" => VersionBump::Minor,
"patch" => VersionBump::Patch,
_ => anyhow::bail!("Invalid bump: use major, minor or patch"),
};
let base = current.clone().unwrap_or_else(crate::versioning::semver::Version::initial);
base.bump(b)
} else {
tagger.calculate_next_version_from_log()?
.ok_or_else(|| anyhow::anyhow!("No releasable commits found since last tag (need feat: or fix:)"))?
};
println!("📦 Current version: {}", current.map(|v| v.to_string()).unwrap_or_else(|| "none".to_string()));
println!("🚀 Next version: v{}", next);
if *dry_run {
println!(" (dry run — no tag created)");
} else {
tagger.create_tag(&next, &format!("Release v{}", next))?;
println!("💡 Push with: torii sync --push");
}
} else {
let tag_name = name.as_deref().ok_or_else(|| anyhow::anyhow!(
"Tag name required (or use --release to auto-bump)"
))?;
repo.create_tag(tag_name, message.as_deref())?;
println!("✅ Tag created: {}", tag_name);
}
}
TagCommands::List => {
repo.list_tags()?;
}
TagCommands::Delete { name } => {
repo.delete_tag(name)?;
println!("✅ Tag deleted: {}", name);
}
TagCommands::Push { name } => {
repo.push_tags(name.as_deref())?;
if let Some(tag) = name {
println!("✅ Pushed tag: {}", tag);
} else {
println!("✅ Pushed all tags");
}
}
TagCommands::Show { name } => {
repo.show_tag(name)?;
}
}
}
Commands::Snapshot { action } => {
let snapshot_mgr = SnapshotManager::new(".")?;
match action {
SnapshotCommands::Create { name } => {
let snapshot_id = snapshot_mgr.create_snapshot(name.as_deref())?;
println!("✅ Snapshot created: {}", snapshot_id);
}
SnapshotCommands::List => {
snapshot_mgr.list_snapshots()?;
}
SnapshotCommands::Restore { id } => {
snapshot_mgr.restore_snapshot(id)?;
println!("✅ Restored snapshot: {}", id);
}
SnapshotCommands::Delete { id } => {
snapshot_mgr.delete_snapshot(id)?;
println!("✅ Deleted snapshot: {}", id);
}
SnapshotCommands::Config { enable, interval } => {
let interval_minutes = interval.as_ref().and_then(|s| s.parse::<u32>().ok());
snapshot_mgr.configure_auto_snapshot(*enable, interval_minutes)?;
println!("✅ Auto-snapshot configuration updated");
}
SnapshotCommands::Stash { name, include_untracked } => {
snapshot_mgr.stash(name.as_deref(), *include_untracked)?;
}
SnapshotCommands::Unstash { id, keep } => {
snapshot_mgr.unstash(id.as_deref(), *keep)?;
}
SnapshotCommands::Undo => {
snapshot_mgr.undo()?;
}
}
}
Commands::Mirror { action } => {
let mirror_mgr = MirrorManager::new(".")?;
match action {
MirrorCommands::Add { platform, account_type, account, repo, primary, protocol } => {
let acc_type = parse_account_type(account_type)?;
let proto = parse_protocol(protocol.as_ref());
mirror_mgr.add_mirror(platform, acc_type, account, repo, proto, *primary)?;
let kind = if *primary { "Primary" } else { "Replica" };
println!("✅ {} mirror added: {}/{} on {}", kind, account, repo, platform);
}
MirrorCommands::List => {
mirror_mgr.list_mirrors()?;
}
MirrorCommands::Sync { force } => {
mirror_mgr.sync_all(*force)?;
}
MirrorCommands::Promote { platform, account } => {
mirror_mgr.set_primary(platform, account)?;
println!("✅ Promoted to primary: {}/{}", platform, account);
}
MirrorCommands::Remove { platform, account } => {
mirror_mgr.remove_mirror_by_account(platform, account)?;
println!("✅ Mirror removed: {}/{}", platform, account);
}
MirrorCommands::Autofetch { enable, disable, interval, status } => {
if *status {
mirror_mgr.show_autofetch_status()?;
} else if *enable {
let interval_minutes = if let Some(interval_str) = interval {
Some(parse_duration(interval_str)?)
} else {
None
};
mirror_mgr.configure_autofetch(true, interval_minutes)?;
} else if *disable {
mirror_mgr.configure_autofetch(false, None)?;
} else {
mirror_mgr.show_autofetch_status()?;
}
}
}
}
Commands::Config { action } => {
match action {
ConfigCommands::Set { key, value, local } => {
if *local {
let mut config = ToriiConfig::load_local(".")?;
config.set(key, value)?;
config.save_local(".")?;
println!("✅ Local config updated: {} = {}", key, value);
} else {
let mut config = ToriiConfig::load_global()?;
config.set(key, value)?;
config.save_global()?;
println!("✅ Global config updated: {} = {}", key, value);
}
}
ConfigCommands::Get { key, local } => {
let config = if *local {
ToriiConfig::load_local(".")?
} else {
ToriiConfig::load_global()?
};
if let Some(value) = config.get(key) {
println!("{}", value);
} else {
println!("❌ Config key not found: {}", key);
}
}
ConfigCommands::List { local } => {
let config = if *local {
ToriiConfig::load_local(".")?
} else {
ToriiConfig::load_global()?
};
let scope = if *local { "Local" } else { "Global" };
println!("⚙️ {} Configuration:\n", scope);
for (key, value) in config.list() {
println!(" {} = {}", key, value);
}
}
ConfigCommands::Edit { local } => {
let config_path = if *local {
std::path::PathBuf::from(".").join(".torii").join("config.toml")
} else {
dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?
.join("torii")
.join("config.toml")
};
if *local {
let config = ToriiConfig::load_local(".")?;
config.save_local(".")?;
} else {
let config = ToriiConfig::load_global()?;
config.save_global()?;
}
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
let status = std::process::Command::new(&editor)
.arg(&config_path)
.status()?;
if status.success() {
println!("✅ Configuration edited");
} else {
println!("❌ Editor exited with error");
}
}
ConfigCommands::Reset { local } => {
let config = ToriiConfig::default();
if *local {
config.save_local(".")?;
println!("✅ Local configuration reset to defaults");
} else {
config.save_global()?;
println!("✅ Global configuration reset to defaults");
}
}
ConfigCommands::CheckSsh => {
run_ssh_check();
}
}
}
Commands::Remote { action } => {
match action {
RemoteCommands::Create { platforms, name, description, public, private: _, push } => {
let platforms: Vec<String> = platforms.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
if platforms.is_empty() {
anyhow::bail!("At least one platform is required");
}
let visibility = if *public { Visibility::Public } else { Visibility::Private };
let multi = platforms.len() > 1;
let mut created: Vec<(String, crate::remote::RemoteRepo)> = Vec::new();
for platform in &platforms {
print!("🚀 {} - ", platform);
match get_platform_client(platform) {
Ok(client) => match client.create_repo(name, description.as_deref(), visibility.clone()) {
Ok(repo) => {
println!("✅ Created");
println!(" URL: {}", repo.url);
println!(" SSH: {}", repo.ssh_url);
created.push((platform.clone(), repo));
}
Err(e) => println!("❌ Failed: {}", e),
},
Err(e) => println!("❌ Platform error: {}", e),
}
}
if multi {
println!("\n📊 Created on {}/{} platforms", created.len(), platforms.len());
}
if *push && !created.is_empty() {
println!("\n📤 Linking remotes and pushing...");
let git_repo = GitRepo::open(".")?;
for (idx, (platform, repo)) in created.iter().enumerate() {
let remote_name = if !multi || idx == 0 { "origin".to_string() } else { platform.clone() };
let _ = git_repo.repository().remote(&remote_name, &repo.ssh_url);
}
git_repo.push(false)?;
println!("✅ Pushed");
}
}
RemoteCommands::Delete { platforms, owner, repo, yes } => {
let platforms: Vec<String> = platforms.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
if platforms.is_empty() {
anyhow::bail!("At least one platform is required");
}
if !yes {
println!("⚠️ Are you sure you want to delete {}/{} on {} platform(s)? This cannot be undone!", owner, repo, platforms.len());
println!(" Run with --yes to confirm");
return Ok(());
}
for platform in &platforms {
print!("🗑️ {} - ", platform);
match get_platform_client(platform) {
Ok(client) => match client.delete_repo(owner, repo) {
Ok(_) => println!("✅ Deleted"),
Err(e) => println!("❌ Failed: {}", e),
},
Err(e) => println!("❌ Platform error: {}", e),
}
}
return Ok(());
}
RemoteCommands::Visibility { platform, owner, repo, public, private } => {
let client = get_platform_client(platform)?;
let visibility = if *public {
Visibility::Public
} else if *private {
Visibility::Private
} else {
println!("❌ Specify --public or --private");
return Ok(());
};
println!("🔒 Changing visibility of {}/{} to {:?}...", owner, repo, visibility);
client.set_visibility(owner, repo, visibility)?;
println!("✅ Visibility updated");
}
RemoteCommands::Configure {
platform, owner, repo, description, homepage, default_branch,
enable_issues, disable_issues, enable_wiki, disable_wiki,
enable_projects, disable_projects
} => {
let client = get_platform_client(platform)?;
let mut settings = RepoSettings::default();
settings.description = description.clone();
settings.homepage = homepage.clone();
settings.default_branch = default_branch.clone();
let mut features = RepoFeatures::default();
if *enable_issues { features.issues = Some(true); }
if *disable_issues { features.issues = Some(false); }
if *enable_wiki { features.wiki = Some(true); }
if *disable_wiki { features.wiki = Some(false); }
if *enable_projects { features.projects = Some(true); }
if *disable_projects { features.projects = Some(false); }
println!("⚙️ Configuring repository {}/{}...", owner, repo);
if settings.description.is_some() || settings.homepage.is_some() || settings.default_branch.is_some() {
client.update_repo(owner, repo, settings)?;
}
if features.issues.is_some() || features.wiki.is_some() || features.projects.is_some() {
client.configure_features(owner, repo, features)?;
}
println!("✅ Repository configured");
}
RemoteCommands::Info { platform, owner, repo } => {
let client = get_platform_client(platform)?;
println!("📊 Fetching repository information...");
let repo_info = client.get_repo(owner, repo)?;
println!("\n📦 Repository: {}", repo_info.name);
if let Some(desc) = &repo_info.description {
println!(" Description: {}", desc);
}
println!(" Visibility: {:?}", repo_info.visibility);
println!(" Default Branch: {}", repo_info.default_branch);
println!(" URL: {}", repo_info.url);
println!(" SSH: {}", repo_info.ssh_url);
}
RemoteCommands::Local => {
let repo = GitRepo::open(".")?;
let git_repo = repo.repository();
let remotes = git_repo.remotes()?;
if remotes.is_empty() {
println!("No remotes configured");
} else {
for name in remotes.iter().flatten() {
if let Ok(remote) = git_repo.find_remote(name) {
let url = remote.url().unwrap_or("(no url)");
println!(" {} {}", name, url);
}
}
}
}
RemoteCommands::Link { name, platform, repo, https, url, force } => {
let resolved_url = if let Some(u) = url {
u.clone()
} else {
let plat = platform.as_deref().ok_or_else(|| anyhow::anyhow!(
"Provide --url <URL> or <platform> <owner>/<repo>"
))?;
let owner_repo = repo.as_deref().ok_or_else(|| anyhow::anyhow!(
"Missing <owner>/<repo>"
))?;
let (ssh_host, https_host) = match plat {
"github" => ("github.com", "github.com"),
"gitlab" => ("gitlab.com", "gitlab.com"),
"codeberg" => ("codeberg.org", "codeberg.org"),
"bitbucket" => ("bitbucket.org", "bitbucket.org"),
"gitea" => ("gitea.com", "gitea.com"),
"forgejo" => ("codeberg.org", "codeberg.org"),
"sourcehut" => ("git.sr.ht", "git.sr.ht"),
_ => anyhow::bail!(
"Unknown platform '{}'. Supported: github, gitlab, codeberg, bitbucket, gitea, forgejo, sourcehut",
plat
),
};
let use_ssh = if *https { false } else { SshHelper::has_ssh_keys() };
if use_ssh {
format!("git@{}:{}.git", ssh_host, owner_repo)
} else {
format!("https://{}/{}.git", https_host, owner_repo)
}
};
let git_repo = GitRepo::open(".")?;
let inner = git_repo.repository();
let exists = inner.find_remote(name).is_ok();
if exists {
if !*force {
anyhow::bail!(
"Remote '{}' already exists. Use --force to overwrite, or 'torii remote local' to inspect.",
name
);
}
inner.remote_set_url(name, &resolved_url)?;
println!("🔗 Updated remote '{}' → {}", name, resolved_url);
} else {
inner.remote(name, &resolved_url)?;
println!("🔗 Linked remote '{}' → {}", name, resolved_url);
}
}
RemoteCommands::List { platform } => {
let client = get_platform_client(platform)?;
println!("📋 Fetching repositories from {}...", platform);
let repos = client.list_repos()?;
if repos.is_empty() {
println!("No repositories found");
} else {
println!("\n📦 Repositories ({}):\n", repos.len());
for repo in repos {
println!(" • {} - {:?}", repo.name, repo.visibility);
if let Some(desc) = &repo.description {
println!(" {}", desc);
}
}
}
}
}
}
Commands::Show { object, blame, lines } => {
let repo = GitRepo::open(".")?;
if *blame {
let file = object.as_deref().ok_or_else(|| anyhow::anyhow!("File path required for --blame"))?;
repo.blame(file, lines.as_deref())?;
} else {
repo.show(object.as_deref())?;
}
}
Commands::History { action } => {
let repo = GitRepo::open(".")?;
match action {
HistoryCommands::Rewrite { start, end } => {
repo.rewrite_history(start, end)?;
println!("✅ History rewritten successfully");
}
HistoryCommands::Clean => {
repo.clean_history()?;
println!("✅ Repository cleaned");
}
HistoryCommands::RemoveFile { file } => {
repo.remove_file_from_history(file)?;
}
HistoryCommands::Rebase { target, interactive, todo_file, root, r#continue, abort, skip } => {
if *r#continue {
repo.rebase_continue()?;
} else if *abort {
repo.rebase_abort()?;
} else if *skip {
repo.rebase_skip()?;
} else if *root {
if let Some(todo) = todo_file {
repo.rebase_root_with_todo(todo)?;
} else {
repo.rebase_root_interactive()?;
}
} else if let Some(todo) = todo_file {
let base = target.as_deref().ok_or_else(|| anyhow::anyhow!("Target required: torii history rebase <base> --todo-file plan.txt (or use --root)"))?;
repo.rebase_with_todo(base, todo)?;
} else if *interactive {
let base = target.as_deref().ok_or_else(|| anyhow::anyhow!("Target required: torii history rebase HEAD~3 --interactive (or use --root)"))?;
repo.rebase_interactive(base)?;
} else if let Some(base) = target {
repo.rebase_branch(base)?;
println!("✅ Rebased onto: {}", base);
} else {
anyhow::bail!("Specify a target or use --root / --interactive / --todo-file / --continue / --abort / --skip");
}
}
}
}
Commands::Workspace { action } => {
use crate::workspace::WorkspaceManager;
match action {
WorkspaceCommands::Add { workspace, path } => {
WorkspaceManager::add(workspace, path)?;
}
WorkspaceCommands::Remove { workspace, path } => {
WorkspaceManager::remove(workspace, path)?;
}
WorkspaceCommands::Delete { workspace } => {
WorkspaceManager::delete(workspace)?;
}
WorkspaceCommands::List => {
WorkspaceManager::list()?;
}
WorkspaceCommands::Status { workspace } => {
WorkspaceManager::status(workspace)?;
}
WorkspaceCommands::Save { workspace, message, all } => {
WorkspaceManager::save(workspace, message, *all)?;
}
WorkspaceCommands::Sync { workspace, force } => {
WorkspaceManager::sync(workspace, *force)?;
}
}
}
Commands::Pr { action } => {
use crate::pr::{get_pr_client, detect_platform_from_remote, CreatePrOptions, MergeMethod};
let repo_path = std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
.to_string_lossy().to_string();
let (platform, owner, repo_name) = detect_platform_from_remote(&repo_path)
.ok_or_else(|| crate::error::ToriiError::InvalidConfig(
"Could not detect platform from remote. Is 'origin' set to a GitHub/GitLab URL?".to_string()
))?;
let client = get_pr_client(&platform)?;
match action {
PrCommands::List { state } => {
let prs = client.list(&owner, &repo_name, state)?;
if prs.is_empty() {
println!("No {} pull requests.", state);
} else {
for pr in &prs {
let draft = if pr.draft { " [draft]" } else { "" };
let merge = match pr.mergeable {
Some(true) => " ✓",
Some(false) => " ✗",
None => "",
};
println!("#{:<5} {}{}{}", pr.number, pr.title, draft, merge);
println!(" {} → {} by {} {}", pr.head, pr.base, pr.author, pr.created_at);
println!(" {}", pr.url);
println!();
}
}
}
PrCommands::Create { title, base, head, description, draft } => {
let head_branch = if let Some(h) = head {
h.clone()
} else {
let repo = git2::Repository::discover(&repo_path)
.map_err(crate::error::ToriiError::Git)?;
repo.head().ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()))
.unwrap_or_else(|| "HEAD".to_string())
};
let opts = CreatePrOptions {
title: title.clone(),
body: description.clone(),
head: head_branch,
base: base.clone(),
draft: *draft,
};
let pr = client.create(&owner, &repo_name, opts)?;
println!("Created PR #{}: {}", pr.number, pr.title);
println!("{}", pr.url);
}
PrCommands::Merge { number, method } => {
let merge_method = match method.as_str() {
"squash" => MergeMethod::Squash,
"rebase" => MergeMethod::Rebase,
_ => MergeMethod::Merge,
};
client.merge(&owner, &repo_name, *number, merge_method)?;
println!("Merged PR #{}", number);
}
PrCommands::Close { number } => {
client.close(&owner, &repo_name, *number)?;
println!("Closed PR #{}", number);
}
PrCommands::Checkout { number } => {
let pr = client.get(&owner, &repo_name, *number)?;
let branch = client.checkout_branch(&pr);
let status = std::process::Command::new("torii")
.args(["branch", &branch])
.status();
match status {
Ok(s) if s.success() => println!("Checked out branch: {}", branch),
_ => eprintln!("Failed to checkout branch: {}", branch),
}
}
PrCommands::Open { number } => {
let pr = client.get(&owner, &repo_name, *number)?;
let _ = std::process::Command::new("xdg-open")
.arg(&pr.url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
println!("Opening: {}", pr.url);
}
}
}
Commands::Issue { action } => {
let repo_path = std::env::current_dir()?.to_string_lossy().to_string();
let (platform, owner, repo_name) = detect_platform_from_remote(&repo_path)
.ok_or_else(|| anyhow::anyhow!("Could not detect platform from remote origin"))?;
let client = get_issue_client(&platform)?;
match action {
IssueCommands::List { state } => {
let issues = client.list(&owner, &repo_name, &state)?;
if issues.is_empty() {
println!("No {} issues.", state);
} else {
for i in &issues {
let labels = if i.labels.is_empty() {
String::new()
} else {
format!(" [{}]", i.labels.join(", "))
};
let comments = if i.comments > 0 { format!(" 💬{}", i.comments) } else { String::new() };
println!("#{:<6} {}{}{}", i.number, i.title, labels, comments);
println!(" {} → {} by {} {}", i.state, i.url, i.author, &i.created_at[..10]);
}
}
}
IssueCommands::Create { title, description } => {
let opts = CreateIssueOptions { title: title.clone(), body: description.clone() };
let issue = client.create(&owner, &repo_name, opts)?;
println!("Created issue #{}: {}", issue.number, issue.title);
println!("{}", issue.url);
}
IssueCommands::Close { number } => {
client.close(&owner, &repo_name, *number)?;
println!("✅ Closed issue #{}", number);
}
IssueCommands::Comment { number, message } => {
client.comment(&owner, &repo_name, *number, message)?;
println!("✅ Comment added to issue #{}", number);
}
}
}
Commands::Tui => {
let current = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
if git2::Repository::discover(¤t).is_ok() {
crate::tui::run()?;
} else {
use crate::tui::picker::{run_picker, save_workspace, PickerResult};
match run_picker(¤t)? {
PickerResult::Cancelled => {}
PickerResult::SingleRepo(path) => {
std::env::set_current_dir(&path)?;
crate::tui::run()?;
}
PickerResult::Workspace { name, repos } => {
save_workspace(&name, &repos)?;
if let Some(first) = repos.first() {
std::env::set_current_dir(first)?;
}
crate::tui::run_with_workspace(name)?;
}
PickerResult::OpenWorkspace(name) => {
let ws_path = dirs::home_dir()
.map(|h| h.join(".torii/workspaces.toml"))
.unwrap_or_default();
if let Ok(content) = std::fs::read_to_string(&ws_path) {
let mut in_ws = false;
let mut first_path: Option<std::path::PathBuf> = None;
for line in content.lines() {
let line = line.trim();
if line == format!("[{}]", name) { in_ws = true; continue; }
if line.starts_with('[') { in_ws = false; }
if in_ws && line.starts_with("path") {
let p = line.split('=').nth(1).unwrap_or("").trim().trim_matches('"');
first_path = Some(std::path::PathBuf::from(p));
break;
}
}
if let Some(p) = first_path {
std::env::set_current_dir(&p)?;
}
}
crate::tui::run_with_workspace(name)?;
}
}
}
}
}
Ok(())
}
}
fn run_ssh_check() {
println!("🔐 SSH Configuration Check\n");
if SshHelper::has_ssh_keys() {
println!("✅ SSH keys found!\n");
let keys = SshHelper::list_keys();
if !keys.is_empty() {
println!("Available keys:");
for key in &keys {
println!(" • {}", key);
}
}
println!("\n💡 Recommendation: Use SSH protocol (default)");
} else {
println!("❌ No SSH keys found");
println!("\n💡 To set up SSH keys:");
println!(" 1. Generate a new key:");
println!(" ssh-keygen -t ed25519 -C \"your_email@example.com\"");
println!(" 2. Start the SSH agent:");
println!(" eval \"$(ssh-agent -s)\"");
println!(" 3. Add your key:");
println!(" ssh-add ~/.ssh/id_ed25519");
println!(" 4. Copy your public key:");
println!(" cat ~/.ssh/id_ed25519.pub");
println!(" 5. Add it to your Git hosting service");
}
}
fn run_scan(history: bool) -> Result<()> {
let repo_path = std::path::Path::new(".");
if history {
println!("🔍 Scanning full git history for sensitive data...\n");
let results = scanner::scan_history(repo_path)?;
if results.is_empty() {
println!("✅ No sensitive data found in history.");
} else {
println!("⚠️ Found sensitive data in {} commit(s):\n", results.len());
for (commit, findings) in &results {
println!(" 📌 {}", commit);
for f in findings {
println!(" {}:{} — {}", f.file, f.line, f.pattern_name);
println!(" {}", f.preview);
}
println!();
}
println!("💡 To clean history: torii history rebase <base> --todo-file <plan>");
}
} else {
println!("🔍 Scanning staged files for sensitive data...\n");
let findings = scanner::scan_staged(repo_path)?;
if findings.is_empty() {
println!("✅ No sensitive data detected in staged files.");
} else {
println!("⚠️ Found {} issue(s):\n", findings.len());
for f in &findings {
println!(" {}:{} — {}", f.file, f.line, f.pattern_name);
println!(" {}\n", f.preview);
}
println!("💡 Tip: use .env.example for placeholder values.");
}
}
Ok(())
}