use anyhow::Context;
use color_print::cformat;
use worktrunk::git::Repository;
use worktrunk::styling::{eprintln, info_message, progress_message, success_message};
use crate::config::resolve_stage_mode;
use crate::types::{PushOutcome, PushResult, StageMode};
use crate::util::{resolve_remote, wip_message};
pub fn push(stage: Option<StageMode>, message: Option<String>) -> anyhow::Result<PushResult> {
let repo = Repository::current()?;
let branch = repo.require_current_branch("wip push")?;
let remote = resolve_remote(&repo, &branch)?;
let stage_mode = resolve_stage_mode(stage, &repo);
match stage_mode {
StageMode::All => {
repo.run_command(&["add", "-A"])
.context("Failed to stage changes")?;
}
StageMode::Tracked => {
repo.run_command(&["add", "-u"])
.context("Failed to stage tracked changes")?;
}
StageMode::None => {
}
}
let has_staged = !repo.run_command_check(&["diff", "--cached", "--quiet"])?;
let committed = if has_staged {
let msg = message.unwrap_or_else(wip_message);
eprintln!("{}", progress_message("Committing wip checkpoint..."));
repo.run_command(&["commit", "-m", &msg])
.context("Failed to create wip commit")?;
let sha = repo.run_command(&["rev-parse", "HEAD"])?.trim().to_string();
Some(sha)
} else {
None
};
let has_upstream = repo.branch(&branch).upstream()?.is_some();
let commits_pushed = if has_upstream {
repo.run_command(&["rev-list", "--count", "@{upstream}..HEAD"])
.ok()
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(0)
} else {
repo.run_command(&["rev-list", "--count", "HEAD"])
.ok()
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(1)
};
eprintln!(
"{}",
progress_message(cformat!(
"Pushing <bold>{branch}</> to <bold>{remote}</>..."
))
);
let push_args: Vec<&str> = if has_upstream {
vec!["push", "--end-of-options", &remote, &branch]
} else {
vec!["push", "-u", "--end-of-options", &remote, &branch]
};
repo.run_command(&push_args).with_context(|| {
format!(
"Push to {remote}/{branch} was rejected — the remote has commits you don't have. Run `wt wip pull` first."
)
})?;
let outcome = if commits_pushed > 0 {
eprintln!(
"{}",
success_message(cformat!("Pushed <bold>{branch}</> to <bold>{remote}</>"))
);
PushOutcome::Pushed
} else {
eprintln!(
"{}",
info_message(cformat!(
"Already up to date with <bold>{remote}/{branch}</>"
))
);
PushOutcome::UpToDate
};
Ok(PushResult {
branch,
remote,
committed,
outcome,
commits_pushed,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{configure, git, in_dir};
use std::fs;
use std::process::Command;
fn setup() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let remote = dir.path().join("remote.git");
let clone = dir.path().join("clone");
git(dir.path(), &["init", "--bare", remote.to_str().unwrap()]);
git(
dir.path(),
&["clone", remote.to_str().unwrap(), clone.to_str().unwrap()],
);
configure(&clone);
git(&clone, &["checkout", "-b", "main"]);
fs::write(clone.join("README.md"), "seed\n").unwrap();
git(&clone, &["add", "-A"]);
git(&clone, &["commit", "-m", "seed"]);
git(&clone, &["push", "-u", "origin", "main"]);
(dir, clone)
}
#[test]
fn push_commits_and_pushes_changes() {
let (_dir, clone) = setup();
fs::write(clone.join("wip.txt"), "wip\n").unwrap();
let result = in_dir(&clone, || push(None, None)).unwrap();
assert!(result.committed.is_some());
assert_eq!(result.outcome, PushOutcome::Pushed);
assert!(result.commits_pushed >= 1);
}
#[test]
fn clean_tree_up_to_date_makes_no_commit() {
let (_dir, clone) = setup();
let result = in_dir(&clone, || push(None, None)).unwrap();
assert!(result.committed.is_none());
assert_eq!(result.outcome, PushOutcome::UpToDate);
assert_eq!(result.commits_pushed, 0);
}
#[test]
fn stage_tracked_excludes_untracked_files() {
let (_dir, clone) = setup();
fs::write(clone.join("untracked.txt"), "new\n").unwrap();
let result = in_dir(&clone, || push(Some(StageMode::Tracked), None)).unwrap();
assert!(result.committed.is_none());
assert!(clone.join("untracked.txt").exists());
}
#[test]
fn custom_message_overrides_generated_one() {
let (_dir, clone) = setup();
fs::write(clone.join("wip.txt"), "wip\n").unwrap();
in_dir(&clone, || push(None, Some("checkpoint: custom".into()))).unwrap();
let out = Command::new("git")
.args(["log", "-1", "--format=%s"])
.current_dir(&clone)
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim(),
"checkpoint: custom"
);
}
#[test]
fn rejected_push_names_wip_pull() {
let (dir, clone) = setup();
let other = dir.path().join("other");
git(
dir.path(),
&[
"clone",
dir.path().join("remote.git").to_str().unwrap(),
other.to_str().unwrap(),
],
);
configure(&other);
fs::write(other.join("other.txt"), "x\n").unwrap();
git(&other, &["add", "-A"]);
git(&other, &["commit", "-m", "other machine"]);
git(&other, &["push", "origin", "main"]);
fs::write(clone.join("wip.txt"), "wip\n").unwrap();
let err = match in_dir(&clone, || push(None, None)) {
Err(e) => e,
Ok(_) => panic!("expected push() to fail on rejected push"),
};
assert!(format!("{err:#}").contains("wt wip pull"), "got: {err:#}");
}
#[test]
fn first_push_sets_upstream() {
let (_dir, clone) = setup();
git(&clone, &["checkout", "-b", "feature"]);
fs::write(clone.join("wip.txt"), "wip\n").unwrap();
let result = in_dir(&clone, || push(None, None)).unwrap();
assert_eq!(result.outcome, PushOutcome::Pushed);
assert!(result.commits_pushed >= 1);
let out = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "feature@{upstream}"])
.current_dir(&clone)
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim(),
"origin/feature"
);
}
}