mod absorb;
mod add;
mod branch;
mod commit;
mod completions;
mod core;
mod diff;
mod drop;
mod fold;
mod git;
mod init;
mod push;
mod reword;
mod show;
mod split;
mod status;
mod swap;
mod switch;
mod trace;
mod tui;
mod update;
use crate::core::{graph, msg, repo, transaction};
use std::io::IsTerminal;
use anyhow::Context;
use clap::builder::styling::{AnsiColor, Styles};
use clap::{Args, Parser, Subcommand, ValueEnum};
use colored::control;
const STYLES: Styles = Styles::styled()
.header(AnsiColor::Yellow.on_default().bold())
.usage(AnsiColor::Yellow.on_default().bold())
.literal(AnsiColor::Green.on_default())
.placeholder(AnsiColor::Blue.on_default());
#[derive(ValueEnum, Clone, Copy)]
enum ThemeArg {
Auto,
Dark,
Light,
}
const GROUPED_COMMANDS: &str = "\
\x1b[1;33mWorkflow:\x1b[0m
\x1b[32minit\x1b[0m Initialize a new integration branch
\x1b[32mupdate\x1b[0m, \x1b[32mup\x1b[0m Pull-rebase and update submodules
\x1b[32mpush\x1b[0m, \x1b[32mpr\x1b[0m Push a branch to remote
\x1b[1;33mStaging:\x1b[0m
\x1b[32madd\x1b[0m Stage files using short IDs or paths [\x1b[32m-p\x1b[0m for interactive hunks]
\x1b[1;33mCommits:\x1b[0m
\x1b[32mcommit\x1b[0m, \x1b[32mci\x1b[0m Create a commit on a feature branch
\x1b[32mfold\x1b[0m Amend, fixup, or move commits [\x1b[32mamend\x1b[0m, \x1b[32mam\x1b[0m, \x1b[32mfixup\x1b[0m, \x1b[32mmv\x1b[0m, \x1b[32mrub\x1b[0m]
\x1b[32mabsorb\x1b[0m Auto-distribute changes into originating commits
\x1b[32msplit\x1b[0m Split a commit into two
\x1b[32mswap\x1b[0m Swap two commits
\x1b[32mreword\x1b[0m, \x1b[32mrw\x1b[0m Reword a commit message or rename a branch
\x1b[32mdrop\x1b[0m, \x1b[32mrm\x1b[0m Drop a change, commit, or branch
\x1b[1;33mBranches:\x1b[0m
\x1b[32mbranch\x1b[0m, \x1b[32mbr\x1b[0m Manage feature branches (create, merge, unmerge)
\x1b[32mswitch\x1b[0m, \x1b[32msw\x1b[0m Switch to any branch for testing (without weaving)
\x1b[1;33mInspection:\x1b[0m
\x1b[32mstatus\x1b[0m Show the branch-aware status (\x1b[34mdefault\x1b[0m command)
\x1b[32mshow\x1b[0m, \x1b[32msh\x1b[0m Show commit details (like git show)
\x1b[32mdiff\x1b[0m, \x1b[32mdi\x1b[0m Show a diff using short IDs (like git diff)
\x1b[32mtrace\x1b[0m Show the latest command trace
\x1b[1;33mRecovery:\x1b[0m
\x1b[32mcontinue\x1b[0m, \x1b[32mc\x1b[0m Resume a paused operation after resolving conflicts
\x1b[32mabort\x1b[0m, \x1b[32ma\x1b[0m Cancel a paused operation and restore original state";
#[derive(Parser)]
#[command(
name = "git-loom",
about = "\x1b[34mWeave your branches together\x1b[0m\nCheckout the full docs here: https://narnaud.github.io/git-loom/",
styles = STYLES,
version,
after_help = GROUPED_COMMANDS,
help_template = "{about-with-newline}\n{usage-heading} {usage}{after-help}\n\n\x1b[1;33mOptions:\x1b[0m\n{options}\n",
)]
struct Cli {
#[arg(long)]
no_color: bool,
#[arg(long, default_value = "auto")]
theme: ThemeArg,
#[arg(short = 'f', long = "files", num_args = 0.., hide = true)]
files: Option<Vec<String>>,
#[arg(default_value = "1", hide = true)]
context: usize,
#[arg(short = 'a', long = "all", hide = true)]
all: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Init {
name: Option<String>,
},
#[command(visible_alias = "up")]
Update {
#[arg(short, long)]
yes: bool,
},
#[command(visible_alias = "pr")]
Push {
branch: Option<String>,
#[arg(long)]
no_pr: bool,
},
Add {
#[arg(num_args = 0..)]
files: Vec<String>,
#[arg(short = 'p', long = "patch")]
patch: bool,
},
#[command(visible_alias = "ci")]
Commit {
#[arg(short = 'b', long = "branch")]
branch: Option<String>,
#[arg(short, long)]
message: Option<String>,
#[arg(short = 'p', long = "patch")]
patch: bool,
files: Vec<String>,
},
#[command(visible_aliases = ["amend", "am", "fixup", "mv", "rub"])]
Fold {
#[arg(short = 'c', long = "create")]
create: bool,
#[arg(short = 'p', long = "patch")]
patch: bool,
#[arg(required = true, num_args = 1..)]
args: Vec<String>,
},
Absorb {
#[arg(short = 'n', long)]
dry_run: bool,
files: Vec<String>,
},
Split {
target: String,
#[arg(short, long)]
message: Option<String>,
#[arg(short = 'p', long = "patch")]
patch: bool,
files: Vec<String>,
},
#[command(visible_alias = "rw")]
Reword {
target: String,
#[arg(short, long)]
message: Option<String>,
},
Swap {
a: String,
b: String,
},
#[command(visible_alias = "rm")]
Drop {
target: String,
#[arg(short, long)]
yes: bool,
},
#[command(visible_alias = "br")]
Branch(BranchCmd),
#[command(visible_alias = "sw")]
Switch {
branch: Option<String>,
},
Status {
#[arg(short = 'f', long = "files", num_args = 0.., value_name = "COMMIT")]
files: Option<Vec<String>>,
#[arg(default_value = "1")]
context: usize,
#[arg(short = 'a', long = "all")]
all: bool,
},
#[command(visible_alias = "sh")]
Show {
target: String,
},
#[command(visible_alias = "di")]
Diff {
args: Vec<String>,
},
Trace,
#[command(visible_alias = "c")]
Continue,
#[command(visible_alias = "a")]
Abort,
#[command(hide = true)]
Completions {
shell: String,
},
#[command(hide = true)]
InternalWriteTodo {
#[arg(long = "source")]
source: String,
todo_file: String,
},
}
#[derive(Args)]
#[command(args_conflicts_with_subcommands = true)]
struct BranchCmd {
#[command(subcommand)]
action: Option<BranchAction>,
#[command(flatten)]
new_args: BranchNewArgs,
}
#[derive(Subcommand)]
enum BranchAction {
#[command(visible_alias = "create")]
New(BranchNewArgs),
Merge {
branch: Option<String>,
#[arg(short = 'a', long = "all")]
all: bool,
},
Unmerge {
branch: Option<String>,
},
}
#[derive(Args, Clone)]
struct BranchNewArgs {
name: Option<String>,
#[arg(short = 't', long = "target")]
target: Option<String>,
}
fn main() {
let cli = Cli::parse();
if cli.no_color
|| std::env::var_os("NO_COLOR").is_some()
|| std::env::var_os("TERM").is_some_and(|v| v == "dumb")
|| !std::io::stdout().is_terminal()
{
control::set_override(false);
}
if let Some(Command::Completions { shell }) = cli.command {
if let Err(e) = completions::run(shell) {
msg::error(&e.to_string());
std::process::exit(1);
}
return;
}
if let Err(e) = git::check_git_version() {
msg::error(&e.to_string());
std::process::exit(1);
}
let should_log = !matches!(
cli.command,
Some(Command::InternalWriteTodo { .. })
| Some(Command::Trace)
| Some(Command::Show { .. })
| Some(Command::Diff { .. })
);
if should_log && let Ok(repo) = repo::open_repo() {
let git_dir = repo.path().to_path_buf();
let cmd_line = std::env::args().collect::<Vec<_>>().join(" ");
if matches!(cli.command, Some(Command::Abort) | Some(Command::Continue)) {
trace::init_appending(&git_dir, &cmd_line);
} else {
trace::init(&git_dir, &cmd_line);
}
}
let is_exempt = matches!(
cli.command,
Some(Command::Show { .. })
| Some(Command::Diff { .. })
| Some(Command::Trace)
| Some(Command::Continue)
| Some(Command::Abort)
| Some(Command::Completions { .. })
| Some(Command::InternalWriteTodo { .. })
);
if !is_exempt && let Ok(repo) = repo::open_repo() {
let git_dir = repo.path().to_path_buf();
if let Ok(Some(state)) = transaction::load(&git_dir) {
msg::error(&format!(
"A `loom {}` is paused due to conflicts.\n\
Run `loom continue` to resume or `loom abort` to cancel.\n\
If no loom operation is in progress, delete `.git/loom/state.json` to reset.",
state.command
));
std::process::exit(1);
}
}
let theme = resolve_theme(cli.theme);
let result = match cli.command {
None => status::run(cli.files, cli.context, cli.all, theme),
Some(Command::Status {
files,
context,
all,
}) => status::run(files, context, all, theme),
Some(Command::Init { name }) => init::run(name),
Some(Command::Add { files, patch }) => add::run(files, patch, &theme),
Some(Command::Switch { branch }) => switch::run(branch),
Some(Command::Branch(cmd)) => match cmd.action {
Some(BranchAction::New(args)) => branch::new::run(args.name, args.target),
Some(BranchAction::Merge { branch, all }) => branch::merge::run(branch, all),
Some(BranchAction::Unmerge { branch }) => branch::unmerge::run(branch),
None => branch::new::run(cmd.new_args.name, cmd.new_args.target),
},
Some(Command::Reword { target, message }) => reword::run(target, message),
Some(Command::Commit {
branch,
message,
patch,
files,
}) => commit::run(branch, message, patch, files, &theme),
Some(Command::Swap { a, b }) => swap::run(a, b),
Some(Command::Drop { target, yes }) => drop::run(target, yes),
Some(Command::Absorb { dry_run, files }) => absorb::run(dry_run, files),
Some(Command::Show { target }) => show::run(target),
Some(Command::Diff { args }) => diff::run(args),
Some(Command::Split {
target,
message,
patch,
files,
}) => split::run(target, message, patch, files, &theme),
Some(Command::Push { branch, no_pr }) => push::run(branch, no_pr),
Some(Command::Update { yes }) => update::run(yes),
Some(Command::Fold {
create,
patch,
args,
}) => fold::run(create, patch, args, &theme),
Some(Command::Trace) => trace::run(),
Some(Command::Continue) => transaction::continue_run(),
Some(Command::Abort) => transaction::abort_run(),
Some(Command::Completions { .. }) => unreachable!(),
Some(Command::InternalWriteTodo { source, todo_file }) => {
handle_write_todo(&source, &todo_file)
}
};
trace::finalize();
if let Err(e) = result {
msg::error(&e.to_string());
std::process::exit(1);
}
}
fn resolve_theme(arg: ThemeArg) -> graph::Theme {
match arg {
ThemeArg::Dark => graph::Theme::dark(),
ThemeArg::Light => graph::Theme::light(),
ThemeArg::Auto => {
if !std::io::stdout().is_terminal() {
return graph::Theme::dark();
}
use terminal_colorsaurus::{QueryOptions, ThemeMode, theme_mode};
match theme_mode(QueryOptions::default()) {
Ok(ThemeMode::Light) => graph::Theme::light(),
_ => graph::Theme::dark(),
}
}
}
}
fn handle_write_todo(source: &str, todo_file: &str) -> anyhow::Result<()> {
if let Ok(original) = std::fs::read_to_string(todo_file) {
let sidecar = format!("{}.original", source);
let _ = std::fs::write(sidecar, original);
}
let content = std::fs::read_to_string(source)
.with_context(|| format!("Failed to read source file '{}'", source))?;
std::fs::write(todo_file, content)
.with_context(|| format!("Failed to write todo file '{}'", todo_file))?;
Ok(())
}