mod absorb;
mod add;
mod agent;
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::agent::AgentKind;
use crate::core::hunk_select::HunkArgs;
use crate::core::{agent_mode, graph, msg, repo, transaction};
use std::ffi::OsString;
use std::io::IsTerminal;
use std::sync::OnceLock;
use anyhow::Context;
use clap::builder::styling::{AnsiColor, Styles};
use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use colored::control;
#[derive(ValueEnum, Clone, Copy)]
enum ThemeArg {
Auto,
Dark,
Light,
}
#[derive(Clone, Copy, PartialEq)]
enum ThemeMode {
Dark,
Light,
}
const ABOUT: &str = "\
{p}Weave your branches together{r}
Checkout the full docs here: https://narnaud.github.io/git-loom/";
const HELP_TEMPLATE: &str =
"{about-with-newline}\n{usage-heading} {usage}{after-help}\n\n{h}Options:{r}\n{options}\n";
const GROUPED_COMMANDS: &str = "\
{h}Workflow:{r}
{l}init{r} Initialize a new integration branch
{l}update{r}, {l}up{r} Pull-rebase and update submodules
{l}push{r}, {l}pr{r} Push a branch to remote
{l}agent{r} Install the loom skill for AI agents
{h}Staging:{r}
{l}add{r} Stage files using short IDs or paths [{l}-p{r} for interactive hunks]
{h}Commits:{r}
{l}commit{r}, {l}ci{r} Create a commit on a feature branch [{l}-p{r} for interactive hunks]
{l}fold{r} Amend, fixup, or move commits [{l}-p{r} for interactive hunks] [{l}amend{r}, {l}am{r}, {l}fixup{r}, {l}mv{r}, {l}rub{r}]
{l}absorb{r} Auto-distribute changes into originating commits
{l}split{r} Split a commit into two [{l}-p{r} for interactive hunks]
{l}swap{r} Swap two commits
{l}reword{r}, {l}rw{r} Reword a commit message or rename a branch
{l}drop{r}, {l}rm{r} Drop a change, commit, or branch
{h}Branches:{r}
{l}branch{r}, {l}br{r} Manage feature branches (create, merge, unmerge)
{l}switch{r}, {l}sw{r} Switch to any branch for testing (without weaving)
{h}Inspection:{r}
{l}status{r} Show the branch-aware status ({p}default{r} command)
{l}tui{r} Interactive status TUI (tree + diff, with actions)
{l}show{r}, {l}sh{r} Show commit details (like git show)
{l}diff{r}, {l}di{r} Show a diff using short IDs (like git diff)
{l}trace{r} Show the latest command trace
{h}Recovery:{r}
{l}continue{r}, {l}c{r} Resume a paused operation after resolving conflicts
{l}abort{r}, {l}a{r} Cancel a paused operation and restore original state";
fn help_styles(mode: ThemeMode, color: bool) -> Styles {
if !color {
return Styles::plain();
}
let (header, literal, placeholder) = match mode {
ThemeMode::Dark => (AnsiColor::Yellow, AnsiColor::Green, AnsiColor::Blue),
ThemeMode::Light => (AnsiColor::Blue, AnsiColor::Green, AnsiColor::Magenta),
};
Styles::styled()
.header(header.on_default().bold())
.usage(header.on_default().bold())
.literal(literal.on_default())
.placeholder(placeholder.on_default())
}
fn apply_styles(template: &str, styles: &Styles) -> String {
let header = styles.get_header();
template
.replace("{h}", &header.to_string())
.replace("{l}", &styles.get_literal().to_string())
.replace("{p}", &styles.get_placeholder().to_string())
.replace("{r}", &format!("{header:#}"))
}
#[derive(Parser)]
#[command(name = "git-loom", version)]
struct Cli {
#[arg(long)]
no_color: bool,
#[arg(long, global = true)]
agent: bool,
#[arg(long, default_value = "auto")]
theme: ThemeArg,
#[arg(short = 'f', long = "files", num_args = 0.., hide = true)]
files: Option<Vec<String>>,
#[arg(hide = true)]
context: Option<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,
#[arg(short = 'f', long)]
force: bool,
},
Agent(AgentCmd),
Add {
#[arg(num_args = 0..)]
files: Vec<String>,
#[arg(short = 'p', long = "patch")]
patch: bool,
#[arg(long = "hunks", requires = "patch", requires = "hunks_from")]
hunks: Vec<String>,
#[arg(long = "hunks-from", requires = "hunks")]
hunks_from: Option<String>,
#[arg(last = true, num_args = 0.., allow_hyphen_values = true, value_name = "GIT_ARG")]
git_args: Vec<String>,
},
#[command(visible_alias = "ci")]
Commit {
#[arg(short = 'b', long = "branch")]
branch: Option<String>,
#[arg(short = 'i', long = "integration", conflicts_with = "branch")]
integration: bool,
#[arg(short, long)]
message: Option<String>,
#[arg(short = 'p', long = "patch")]
patch: bool,
#[arg(long = "hunks", requires = "patch", requires = "hunks_from")]
hunks: Vec<String>,
#[arg(long = "hunks-from", requires = "hunks")]
hunks_from: Option<String>,
files: Vec<String>,
#[arg(last = true, num_args = 0.., allow_hyphen_values = true, value_name = "GIT_ARG")]
git_args: Vec<String>,
},
#[command(visible_aliases = ["amend", "am", "fixup", "mv", "rub"])]
Fold {
#[arg(short = 'c', long = "create")]
create: bool,
#[arg(short = 'p', long = "patch", conflicts_with = "create")]
patch: bool,
#[arg(long, value_name = "COMMIT", conflicts_with_all = ["below", "create", "patch"])]
above: Option<String>,
#[arg(long, value_name = "COMMIT", conflicts_with_all = ["create", "patch"])]
below: Option<String>,
#[arg(long = "hunks", requires = "patch", requires = "hunks_from")]
hunks: Vec<String>,
#[arg(long = "hunks-from", requires = "hunks")]
hunks_from: Option<String>,
#[arg(required = true, num_args = 1..)]
args: Vec<String>,
#[arg(last = true, num_args = 0.., allow_hyphen_values = true, value_name = "GIT_ARG")]
git_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,
#[arg(long = "hunks", requires = "patch", requires = "hunks_from")]
hunks: Vec<String>,
#[arg(long = "hunks-from", requires = "hunks")]
hunks_from: Option<String>,
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 {
#[arg(required = true)]
targets: Vec<String>,
#[arg(short, long)]
yes: bool,
},
#[command(visible_alias = "br")]
Branch(BranchCmd),
#[command(visible_alias = "sw")]
Switch {
branch: Option<String>,
},
Tui,
Status {
#[arg(short = 'f', long = "files", num_args = 0.., value_name = "COMMIT")]
files: Option<Vec<String>>,
context: Option<usize>,
#[arg(short = 'a', long = "all")]
all: bool,
},
#[command(visible_alias = "sh")]
Show {
target: Option<String>,
#[arg(last = true, num_args = 0.., allow_hyphen_values = true, value_name = "GIT_ARG")]
git_args: Vec<String>,
},
#[command(visible_alias = "di")]
Diff {
#[arg(num_args = 0..)]
args: Vec<String>,
#[arg(long = "staged", visible_alias = "cached", conflicts_with = "all")]
staged: bool,
#[arg(short = 'a', long = "all")]
all: bool,
#[arg(last = true, num_args = 0.., allow_hyphen_values = true, value_name = "GIT_ARG")]
git_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)]
struct AgentCmd {
#[command(subcommand)]
action: AgentAction,
}
#[derive(Subcommand)]
enum AgentAction {
Init {
#[arg(value_enum, default_value = "claude", value_name = "AGENT")]
kind: AgentKind,
#[arg(long, conflicts_with = "dir")]
project: bool,
#[arg(long, hide = true)]
dir: Option<std::path::PathBuf>,
},
}
#[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 paused_state_message(command: &str, interrupted: bool) -> String {
if interrupted {
format!(
"A `loom {command}` is paused, with a rebase or merge still in progress.\n\
Resolve what stopped it, then run `loom continue` to resume, or `loom abort` to cancel."
)
} else {
format!(
"A `loom {command}` is paused, but no rebase is in progress.\n\
If you finished it yourself, run `loom continue` to wrap up and clear the state.\n\
Run `loom abort` to discard it instead."
)
}
}
fn stray_rebase_message(rebasing: bool, step: Option<(usize, usize)>) -> String {
let what = if rebasing { "rebase" } else { "merge" };
let progress = match step {
Some((current, total)) => format!(" (stopped at step {current}/{total})"),
None => String::new(),
};
format!(
"A git {what} is in progress{progress}, but no loom operation is recorded.\n\
Resolve any conflicts and run `loom continue` to finish it, or `loom abort` to cancel it."
)
}
fn main() {
let cli = parse_cli(&std::env::args_os().collect::<Vec<_>>());
let never_agent = matches!(
cli.command,
Some(Command::InternalWriteTodo { .. }) | Some(Command::Completions { .. })
);
agent_mode::set(
!never_agent
&& (cli.agent
|| std::env::var_os("LOOM_AGENT").is_some_and(|v| !v.is_empty() && v != "0")),
);
if !colors_enabled(cli.no_color, msg::human_stream_is_terminal()) {
control::set_override(false);
}
if let Some(Command::Completions { shell }) = cli.command {
finish_and_exit(completions::run(shell));
}
if let Some(Command::Agent(cmd)) = cli.command {
let result = match cmd.action {
AgentAction::Init { kind, project, dir } => agent::init::run(kind, project, dir),
};
finish_and_exit(result);
}
if let Err(e) = git::check_git_version() {
finish_and_exit(Err(e));
}
let should_log = !matches!(
cli.command,
Some(Command::InternalWriteTodo { .. })
| Some(Command::Trace)
| Some(Command::Show { .. })
| Some(Command::Diff { .. })
| Some(Command::Tui)
);
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();
let rebasing = git::rebase_is_in_progress(&git_dir);
let interrupted = rebasing || git::merge_is_in_progress(&git_dir);
if let Ok(Some(state)) = transaction::load(&git_dir) {
finish_and_exit(Err(anyhow::anyhow!(paused_state_message(
&state.command,
interrupted
))));
} else if interrupted {
let step = if rebasing {
git::rebase_progress(&git_dir)
} else {
None
};
finish_and_exit(Err(anyhow::anyhow!(stray_rebase_message(rebasing, step))));
}
}
if agent_mode::enabled() && matches!(cli.command, Some(Command::Tui)) {
finish_and_exit(Err(anyhow::anyhow!(
"the TUI is interactive and unavailable in agent mode\n\
Use `loom status` instead"
)));
}
let theme = graph_theme(resolve_theme_mode(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::Tui) => tui::app::run(theme),
Some(Command::Init { name }) => init::run(name),
Some(Command::Add {
files,
patch,
hunks,
hunks_from,
git_args,
}) => add::run(
files,
patch,
HunkArgs::new(hunks, hunks_from),
git_args,
&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,
integration,
message,
patch,
hunks,
hunks_from,
files,
git_args,
}) => commit::run(
branch,
integration,
message,
patch.then_some(HunkArgs::new(hunks, hunks_from)),
files,
git_args,
&theme,
),
Some(Command::Swap { a, b }) => swap::run(a, b),
Some(Command::Drop { targets, yes }) => drop::run(targets, yes),
Some(Command::Absorb { dry_run, files }) => absorb::run(dry_run, files),
Some(Command::Show { target, git_args }) => show::run(target, git_args),
Some(Command::Diff {
args,
staged,
all,
git_args,
}) => diff::run(args, staged, all, git_args),
Some(Command::Split {
target,
message,
patch,
hunks,
hunks_from,
files,
}) => split::run(
target,
message,
patch,
HunkArgs::new(hunks, hunks_from),
files,
&theme,
),
Some(Command::Push {
branch,
no_pr,
force,
}) => push::run(branch, no_pr, force),
Some(Command::Update { yes }) => update::run(yes),
Some(Command::Fold {
create,
patch,
above,
below,
hunks,
hunks_from,
args,
git_args,
}) => {
let anchor = above
.map(fold::Anchor::Above)
.or(below.map(fold::Anchor::Below));
fold::run(
create,
patch,
anchor,
HunkArgs::new(hunks, hunks_from),
args,
git_args,
&theme,
)
}
Some(Command::Trace) => trace::run(),
Some(Command::Continue) => transaction::continue_run(),
Some(Command::Abort) => transaction::abort_run(),
Some(Command::Completions { .. }) | Some(Command::Agent(_)) => unreachable!(),
Some(Command::InternalWriteTodo { source, todo_file }) => {
handle_write_todo(&source, &todo_file)
}
};
trace::finalize();
finish_and_exit(result);
}
fn finish_and_exit(result: anyhow::Result<()>) -> ! {
if agent_mode::enabled() {
agent::init::report_outdated_skills();
if let Err(e) = &result
&& e.downcast_ref::<agent_mode::NeedsInput>().is_none()
{
msg::error(&e.to_string());
}
std::process::exit(agent_mode::finish(&result));
}
if let Err(e) = result {
msg::error(&e.to_string());
std::process::exit(1);
}
std::process::exit(0);
}
fn parse_cli(args: &[OsString]) -> Cli {
let color = colors_enabled(
args.iter().any(|arg| arg == "--no-color"),
std::io::stdout().is_terminal(),
);
let mode = if color {
resolve_theme_mode(early_theme(args))
} else {
ThemeMode::Dark
};
let styles = help_styles(mode, color);
let mut command = Cli::command()
.styles(styles.clone())
.about(apply_styles(ABOUT, &styles))
.after_help(apply_styles(GROUPED_COMMANDS, &styles))
.help_template(apply_styles(HELP_TEMPLATE, &styles));
if !color {
command = command.color(clap::ColorChoice::Never);
}
let matches = command.get_matches_from(args);
match Cli::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(err) => err.exit(),
}
}
fn colors_enabled(no_color: bool, is_terminal: bool) -> bool {
!no_color
&& std::env::var_os("NO_COLOR").is_none()
&& std::env::var_os("TERM").is_none_or(|v| v != "dumb")
&& is_terminal
}
fn early_theme(args: &[OsString]) -> ThemeArg {
let mut rest = args.iter().skip(1).filter_map(|arg| arg.to_str());
while let Some(arg) = rest.next() {
let value = match arg.strip_prefix("--theme") {
Some("") => rest.next(),
Some(tail) => tail.strip_prefix("="),
None => continue,
};
if let Some(value) = value
&& let Ok(theme) = ThemeArg::from_str(value, true)
{
return theme;
}
}
ThemeArg::Auto
}
fn resolve_theme_mode(arg: ThemeArg) -> ThemeMode {
match arg {
ThemeArg::Dark => ThemeMode::Dark,
ThemeArg::Light => ThemeMode::Light,
ThemeArg::Auto => detect_theme_mode(),
}
}
fn detect_theme_mode() -> ThemeMode {
static DETECTED: OnceLock<ThemeMode> = OnceLock::new();
*DETECTED.get_or_init(|| {
if !msg::human_stream_is_terminal() {
return ThemeMode::Dark;
}
use terminal_colorsaurus::{QueryOptions, ThemeMode as Detected, theme_mode};
match theme_mode(QueryOptions::default()) {
Ok(Detected::Light) => ThemeMode::Light,
_ => ThemeMode::Dark,
}
})
}
fn graph_theme(mode: ThemeMode) -> graph::Theme {
match mode {
ThemeMode::Dark => graph::Theme::dark(),
ThemeMode::Light => graph::Theme::light(),
}
}
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(())
}
#[cfg(test)]
#[path = "main_test.rs"]
mod tests;