use anyhow::{Context, Result};
use clap::Parser;
use crate::commit::{
DEFAULT_MSG, detect_wip_commits, generate_commit_message, is_conventional_commit,
suggest_conventional_type, validate_commit_message,
};
use crate::config::Config;
use crate::git::{
abort_rebase_or_merge, get_commit_counts, get_current_branch, git, has_merge_conflicts,
is_merge_in_progress, is_rebase_in_progress, validate_git_repo,
};
use crate::hooks::{run_post_push_hooks, run_pre_push_hooks};
use crate::interactive::{confirm, get_env_bool, get_env_var, prompt_with_default};
use crate::output::{Output, show_summary};
use crate::status::{
count_files_to_stage, has_changes_to_stage, has_staged_changes, has_uncommitted_changes,
show_status,
};
use crate::types::{
BlockingState, ExecutionMode, PullMode, PushMode, StashMode, Summary, WorkflowConfig,
};
#[derive(Debug, Parser)]
#[command(
name = "git-send",
version,
about = "Stage, commit, pull, and push changes with a single command",
long_about = "git-send automates the common git workflow: staging all changes, committing, \
pulling with rebase, and pushing to remote.\n\n\
Examples:\n \
git-send -m \"fix: resolve bug\"\n \
git-send --auto-message --verbose\n \
git-send --interactive\n \
git-send --dry-run\n\n\
Environment Variables:\n \
GIT_SEND_MESSAGE - Default commit message\n \
GIT_SEND_AUTO_STASH - Enable auto-stash (set to '1' or 'true')\n \
GIT_SEND_SKIP_HOOKS - Skip pre-push hooks (set to '1' or 'true')\n\n\
Exit Codes:\n \
0 - Success\n \
1 - Error occurred\n \
2 - Invalid arguments\n\n\
For more information, see: https://git.sr.ht/~anhkhoakz/git-send"
)]
#[allow(clippy::struct_excessive_bools)]
pub struct Args {
#[arg(short, long, value_name = "MESSAGE")]
pub message: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub no_pull: bool,
#[arg(long)]
pub no_push: bool,
#[arg(long)]
pub auto_stash: bool,
#[arg(short, long)]
pub verbose: bool,
#[arg(short, long)]
pub quiet: bool,
#[arg(short, long)]
pub interactive: bool,
#[arg(long)]
pub amend: bool,
#[arg(long)]
pub force_with_lease: bool,
#[arg(long, value_name = "BRANCH")]
pub upstream: Option<String>,
#[arg(long)]
pub abort: bool,
#[arg(long)]
pub auto_message: bool,
#[arg(long)]
pub skip_hooks: bool,
#[arg(long, short = 'y')]
pub yes: bool,
#[arg(long)]
pub json: bool,
}
fn handle_abort_flag(output: &Output) -> Result<()> {
output.info("Aborting ongoing rebase/merge...");
abort_rebase_or_merge()?;
output.success("Aborted successfully");
Ok(())
}
fn report_blocking_state(output: &Output, state: BlockingState) -> Result<()> {
match state {
BlockingState::Rebase => {
output.error("A rebase is in progress. Please complete or abort it first.");
output.println(" To continue: git rebase --continue");
output.println(" To abort: git rebase --abort");
output.println(" Or use: git-send --abort");
anyhow::bail!("Rebase in progress. Resolve conflicts or abort to continue.")
}
BlockingState::Merge => {
output.error("A merge is in progress. Please complete or abort it first.");
output.println(" To continue: git merge --continue");
output.println(" To abort: git merge --abort");
output.println(" Or use: git-send --abort");
anyhow::bail!("Merge in progress. Complete or abort to continue.")
}
BlockingState::Conflicts => {
output.error("Merge conflicts detected. Resolve before running git-send.");
output.println(" 1. Check conflicted files: git status");
output.println(" 2. Resolve conflicts in each file");
output.println(" 3. Stage resolved files: git add <file>");
output.println(" 4. Continue: git rebase --continue (if rebasing)");
output.println(" 5. Then re-run: git-send");
anyhow::bail!("Merge conflicts detected. Resolve conflicts first.")
}
}
}
fn validate_repository_state(output: &Output) -> Result<()> {
validate_git_repo().context(
"Not in a git repository. Initialize with 'git init' or navigate to a git repository.",
)?;
if is_rebase_in_progress()? {
return report_blocking_state(output, BlockingState::Rebase);
}
if is_merge_in_progress()? {
return report_blocking_state(output, BlockingState::Merge);
}
Ok(())
}
const fn get_execution_mode(args: &Args) -> ExecutionMode {
if args.dry_run {
return ExecutionMode::DryRun;
}
ExecutionMode::Execute
}
const fn get_pull_mode(args: &Args) -> PullMode {
if args.no_pull {
return PullMode::Disabled;
}
PullMode::Enabled
}
const fn get_push_mode(args: &Args) -> PushMode {
if args.no_push {
return PushMode::Disabled;
}
PushMode::Enabled
}
fn get_stash_mode_with_config(args: &Args, config: &Config) -> StashMode {
if args.auto_stash {
return StashMode::Auto;
}
if get_env_bool("GIT_SEND_AUTO_STASH") {
return StashMode::Auto;
}
if config.auto_stash == Some(true) {
return StashMode::Auto;
}
StashMode::Disabled
}
fn get_interactive_message(args: &Args, output: &Output) -> Result<String> {
let default = if args.auto_message {
generate_commit_message()
} else {
DEFAULT_MSG.to_string()
};
if let Some(suggested_type) = suggest_conventional_type() {
output.info(&format!(
"Suggested type: {suggested_type} (conventional commits)"
));
}
let input = prompt_with_default("Commit message", &default)?;
if !input.trim().is_empty() {
return Ok(input);
}
Ok(default)
}
fn get_message_from_env() -> Option<String> {
get_env_var("GIT_SEND_MESSAGE")
}
fn get_commit_message_with_config(
args: &Args,
execution_mode: ExecutionMode,
output: &Output,
config: &Config,
) -> Result<String> {
if let Some(ref m) = args.message {
return Ok(m.clone());
}
if let Some(env_msg) = get_message_from_env()
&& !env_msg.trim().is_empty()
{
return Ok(env_msg);
}
if args.auto_message {
return Ok(generate_commit_message());
}
if args.interactive && matches!(execution_mode, ExecutionMode::Execute) {
return get_interactive_message(args, output);
}
if let Some(ref config_msg) = config.default_message
&& !config_msg.trim().is_empty()
{
return Ok(config_msg.clone());
}
Ok(DEFAULT_MSG.to_string())
}
fn suggest_conventional_format_if_needed(msg: &str, args: &Args, output: &Output) {
if is_conventional_commit(msg) {
return;
}
if args.quiet {
return;
}
if let Some(suggested_type) = suggest_conventional_type() {
output.warning(&format!(
"Consider using conventional commit format: {suggested_type}: <description>"
));
}
}
fn show_wip_commits_if_found(
args: &Args,
execution_mode: ExecutionMode,
output: &Output,
) -> Result<()> {
if !args.interactive {
return Ok(());
}
if !matches!(execution_mode, ExecutionMode::Execute) {
return Ok(());
}
let wip_commits = detect_wip_commits()?;
if wip_commits.is_empty() {
return Ok(());
}
output.warning(&format!("Found {} WIP commit(s):", wip_commits.len()));
for commit in &wip_commits {
output.println(&format!(" - {commit}"));
}
if confirm("Would you like to squash these commits?", false, args.yes)? {
output.info("Use 'git rebase -i HEAD~N' to squash commits manually");
}
Ok(())
}
fn report_merge_conflicts(output: &Output) -> Result<()> {
report_blocking_state(output, BlockingState::Conflicts)
}
fn stage_changes_if_needed(args: &Args, summary: &mut Summary, output: &Output) -> Result<()> {
let has_changes = has_changes_to_stage()?;
if !has_changes {
summary.skipped_stage = true;
if args.verbose {
output.warning("No changes to stage");
}
return Ok(());
}
if args.verbose {
output.progress("Staging changes... ");
}
output.command("git add -A");
git(&["add", "-A"]).context("Failed to stage changes")?;
if args.verbose {
output.success("Staged");
}
summary.files_staged = count_files_to_stage()?;
Ok(())
}
fn build_commit_args(msg: &str, amend: bool) -> Vec<&str> {
if amend {
return vec!["commit", "--amend", "-m", msg];
}
vec!["commit", "-m", msg]
}
fn commit_changes_if_staged(
args: &Args,
msg: &str,
summary: &mut Summary,
output: &Output,
) -> Result<()> {
let has_staged = has_staged_changes()?;
if !has_staged {
summary.skipped_commit = true;
if args.verbose {
output.warning("Nothing to commit (working tree clean)");
}
return Ok(());
}
if args.verbose {
output.progress("Committing changes... ");
}
let commit_args = build_commit_args(msg, args.amend);
let cmd = if args.amend {
format!("git commit --amend -m \"{msg}\"")
} else {
format!("git commit -m \"{msg}\"")
};
output.command(&cmd);
git(&commit_args).context("Failed to commit changes")?;
if args.verbose {
output.success("Committed");
}
summary.commits_created = 1;
Ok(())
}
fn should_stash(pull_mode: PullMode, stash_mode: StashMode) -> Result<bool> {
if !matches!(pull_mode, PullMode::Enabled) {
return Ok(false);
}
if !matches!(stash_mode, StashMode::Auto) {
return Ok(false);
}
has_uncommitted_changes()
}
fn stash_changes_if_needed(
args: &Args,
pull_mode: PullMode,
stash_mode: StashMode,
summary: &mut Summary,
output: &Output,
) -> Result<bool> {
let needs_stash = should_stash(pull_mode, stash_mode)?;
if !needs_stash {
return Ok(false);
}
if args.verbose {
output.progress("Stashing uncommitted changes... ");
}
output.command("git stash push -u");
git(&["stash", "push", "-u"]).context("Failed to stash changes")?;
if args.verbose {
output.success("Stashed");
}
summary.stashed = true;
Ok(true)
}
fn handle_stash_conflicts(output: &Output) -> Result<()> {
output.error("Stash pop resulted in conflicts.");
output.println(" 1. Resolve conflicts in each file");
output.println(" 2. Stage resolved files: git add <file>");
output.println(" 3. Drop the stash: git stash drop");
output.println(" 4. Re-run: git-send");
anyhow::bail!("Stash conflicts detected. Resolve manually before continuing.")
}
fn stash_pop_safe(output: &Output) -> Result<()> {
output.command("git stash pop");
let result = git(&["stash", "pop"]);
if let Err(e) = result {
if has_merge_conflicts().unwrap_or(false) {
return handle_stash_conflicts(output);
}
return Err(e.context("Failed to restore stashed changes"));
}
Ok(())
}
fn report_rebase_conflict(output: &Output) -> Result<()> {
output.error("Merge conflicts occurred during rebase.");
output.println("Please resolve conflicts manually:");
output.println(" 1. Resolve conflicts in the files");
output.println(" 2. Run 'git add <file>' for each resolved file");
output.println(" 3. Run 'git rebase --continue'");
output.println(" 4. Re-run git-send when done");
anyhow::bail!("Merge conflicts during rebase");
}
fn pull_if_behind(
args: &Args,
branch: &str,
upstream: &str,
pull_mode: PullMode,
summary: &mut Summary,
output: &Output,
) -> Result<()> {
if !matches!(pull_mode, PullMode::Enabled) {
return Ok(());
}
if args.verbose {
output.progress(&format!("Pulling with rebase from origin/{upstream}... "));
}
let (_ahead, behind) = get_commit_counts(branch, &format!("origin/{upstream}"));
if behind == 0 {
summary.skipped_pull = true;
if args.verbose {
output.warning(&format!("Already up to date with origin/{upstream}"));
}
return Ok(());
}
output.command(&format!("git pull --rebase origin {upstream}"));
let result = git(&["pull", "--rebase", "origin", upstream]);
if let Err(e) = result {
if has_merge_conflicts().unwrap_or(false) {
return report_rebase_conflict(output);
}
return Err(e.context("Failed to pull changes"));
}
if args.verbose {
output.success(&format!("Pulled {behind} commit(s)"));
}
summary.commits_pulled = behind;
Ok(())
}
fn warn_and_confirm_force_push(args: &Args, output: &Output) -> Result<bool> {
if !args.force_with_lease {
return Ok(true);
}
output.warning("Warning: Using --force-with-lease. This will overwrite remote history.");
if !args.interactive {
return Ok(true);
}
if !confirm("Continue with force push?", false, args.yes)? {
output.info("Push cancelled by user");
return Ok(false);
}
Ok(true)
}
fn build_push_args(upstream: &str, force_with_lease: bool) -> Vec<&str> {
if force_with_lease {
return vec!["push", "--force-with-lease", "origin", upstream];
}
vec!["push", "origin", upstream]
}
fn confirm_push_if_interactive(
args: &Args,
ahead: usize,
upstream: &str,
output: &Output,
) -> Result<bool> {
if !args.interactive {
return Ok(true);
}
let confirmed = confirm(
&format!("Push {} commit(s) to origin/{}?", ahead.max(1), upstream),
true,
args.yes,
)?;
if confirmed {
return Ok(true);
}
output.info("Push cancelled by user");
Ok(false)
}
fn push_if_ahead(
args: &Args,
branch: &str,
upstream: &str,
push_mode: PushMode,
summary: &mut Summary,
stashed: bool,
output: &Output,
) -> Result<()> {
if !matches!(push_mode, PushMode::Enabled) {
return Ok(());
}
let (ahead, _) = get_commit_counts(branch, &format!("origin/{upstream}"));
if ahead == 0 && summary.commits_created == 0 {
summary.skipped_push = true;
if args.verbose {
output.warning("Nothing to push (already up to date)");
}
return Ok(());
}
let should_continue = warn_and_confirm_force_push(args, output)?;
if !should_continue {
if stashed {
stash_pop_safe(output)?;
}
return Ok(());
}
let should_skip_hooks = args.skip_hooks || get_env_bool("GIT_SEND_SKIP_HOOKS");
if !should_skip_hooks && let Err(e) = run_pre_push_hooks(output) {
if stashed {
stash_pop_safe(output)?;
}
return Err(e);
}
let confirmed = confirm_push_if_interactive(args, ahead, upstream, output)?;
if !confirmed {
if stashed {
stash_pop_safe(output)?;
}
return Ok(());
}
if args.verbose {
output.progress(&format!("Pushing to origin/{upstream}... "));
}
let push_args = build_push_args(upstream, args.force_with_lease);
let cmd = if args.force_with_lease {
format!("git push --force-with-lease origin {upstream}")
} else {
format!("git push origin {upstream}")
};
output.command(&cmd);
git(&push_args).context("Failed to push changes")?;
if args.verbose {
output.success("Pushed");
}
summary.pushed = true;
let should_skip_hooks = args.skip_hooks || get_env_bool("GIT_SEND_SKIP_HOOKS");
if !should_skip_hooks {
run_post_push_hooks(output)?;
}
Ok(())
}
fn restore_stash_if_needed(args: &Args, stashed: bool, output: &Output) -> Result<()> {
if !stashed {
return Ok(());
}
if args.verbose {
output.progress("Restoring stashed changes... ");
}
stash_pop_safe(output)?;
if args.verbose {
output.success("Restored");
}
Ok(())
}
fn execute_dry_run(
args: &Args,
pull_mode: PullMode,
push_mode: PushMode,
msg: &str,
upstream: &str,
output: &Output,
) {
output.println("Would execute:");
output.println(" git add -A");
if args.amend {
output.println(&format!(" git commit --amend -m '{msg}'"));
} else {
output.println(&format!(" git commit -m '{msg}'"));
}
if matches!(pull_mode, PullMode::Enabled) {
output.println(&format!(" git pull --rebase origin {upstream}"));
}
if matches!(push_mode, PushMode::Enabled) {
if args.force_with_lease {
output.println(&format!(" git push --force-with-lease origin {upstream}"));
} else {
output.println(&format!(" git push origin {upstream}"));
}
}
}
fn execute_workflow(args: &Args, config: &WorkflowConfig, output: &Output) -> Result<()> {
let mut summary = Summary::default();
if has_merge_conflicts()? {
return report_merge_conflicts(output);
}
stage_changes_if_needed(args, &mut summary, output)?;
commit_changes_if_staged(args, config.message, &mut summary, output)?;
let stashed = stash_changes_if_needed(
args,
config.pull_mode,
config.stash_mode,
&mut summary,
output,
)?;
pull_if_behind(
args,
config.branch,
config.upstream,
config.pull_mode,
&mut summary,
output,
)?;
push_if_ahead(
args,
config.branch,
config.upstream,
config.push_mode,
&mut summary,
stashed,
output,
)?;
restore_stash_if_needed(args, stashed, output)?;
show_summary(&summary, output);
Ok(())
}
pub fn run() -> Result<()> {
let args = Args::parse();
let config = Config::load();
let output = Output::new(args.quiet, args.json);
if args.abort {
return handle_abort_flag(&output);
}
validate_repository_state(&output)?;
if let Some(ref msg) = args.message {
validate_commit_message(msg)?;
}
let execution_mode = get_execution_mode(&args);
let pull_mode = get_pull_mode(&args);
let push_mode = get_push_mode(&args);
let stash_mode = get_stash_mode_with_config(&args, &config);
let branch = get_current_branch()?;
let upstream = args.upstream.as_deref().unwrap_or(&branch);
if args.interactive && matches!(execution_mode, ExecutionMode::Execute) {
show_status(args.verbose, &output)?;
output.println("");
}
show_wip_commits_if_found(&args, execution_mode, &output)?;
let msg = get_commit_message_with_config(&args, execution_mode, &output, &config)?;
validate_commit_message(&msg)?;
suggest_conventional_format_if_needed(&msg, &args, &output);
if matches!(execution_mode, ExecutionMode::DryRun) {
execute_dry_run(&args, pull_mode, push_mode, &msg, upstream, &output);
return Ok(());
}
let workflow_config = WorkflowConfig {
message: &msg,
branch: &branch,
upstream,
pull_mode,
push_mode,
stash_mode,
};
execute_workflow(&args, &workflow_config, &output)
}