Files
sausage/userland/git/src/lib.rs
T

1580 lines
53 KiB
Rust

//! Local Git operations adapted from the v2 gix plugin, without RPC or host imports.
#![allow(dead_code)]
mod branches;
mod changes;
mod checkout;
mod http;
mod ssh;
mod merge;
mod network;
mod output;
mod push;
mod rebase;
mod remotes;
mod repo;
mod staging;
mod worktrees;
use gix::bstr::ByteSlice;
use repo::error;
#[derive(Debug)]
pub struct Error {
pub message: String,
}
impl Error {
pub fn new(_: i32, message: String) -> Self {
Self { message }
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(serde::Serialize, serde::Deserialize)]
pub struct HeadInfo {
detached: bool,
unborn: bool,
branch: Option<String>,
id: Option<String>,
}
include!("head.inc");
struct Identity {
name: Option<String>,
email: Option<String>,
}
fn identity(name: Option<String>, email: Option<String>) -> Result<Identity> {
let repo = gix::discover(".").map_err(|e| error("identity", e))?;
let config = repo.config_snapshot();
Ok(Identity {
name: name
.or_else(|| std::env::var("GIT_AUTHOR_NAME").ok())
.or_else(|| config.string("user.name").map(|s| s.to_string())),
email: email
.or_else(|| std::env::var("GIT_AUTHOR_EMAIL").ok())
.or_else(|| config.string("user.email").map(|s| s.to_string())),
})
}
mod cherry_pick;
mod history;
mod local;
mod plumbing;
mod stash;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "git", disable_help_subcommand = true)]
pub struct Cli {
#[arg(short = 'C', global = true)]
directory: Option<std::path::PathBuf>,
#[arg(short = 'c', global = true)]
config: Vec<String>,
#[arg(short = 'q', long, global = true)]
quiet: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Show {
#[arg(long)]
stat: bool,
revision: Option<String>,
},
Clone {
url: String,
path: Option<String>,
},
Fetch {
#[arg(default_value = "origin")]
remote: String,
revision: Option<String>,
},
MergeBase {
#[arg(long)]
is_ancestor: bool,
a: String,
b: String,
},
RevList {
#[arg(long)]
count: bool,
revisions: Vec<String>,
},
SymbolicRef {
#[arg(long)]
short: bool,
name: String,
},
UpdateRef {
name: String,
new: String,
old: Option<String>,
},
Clean {
#[arg(short = 'f')]
force: bool,
#[arg(short = 'd')]
directories: bool,
},
CheckIgnore {
#[arg(long)]
no_index: bool,
paths: Vec<String>,
},
Push {
#[arg(short = 'u', long)]
set_upstream: bool,
#[arg(short = 'f', long)]
force: bool,
#[arg(default_value = "origin")]
remote: String,
refspec: Option<String>,
},
Init {
#[arg(long)]
bare: bool,
#[arg(short = 'b', default_value = "main")]
initial_branch: String,
path: Option<String>,
},
Config {
#[arg(long)]
local: bool,
#[arg(long)]
get: bool,
key: String,
value: Option<String>,
},
RevParse {
#[arg(long)]
verify: bool,
#[arg(long)]
show_toplevel: bool,
#[arg(long)]
git_dir: bool,
#[arg(long)]
abbrev_ref: bool,
#[arg(long)]
short: bool,
revisions: Vec<String>,
},
Status {
#[arg(long, default_value = "normal")]
untracked_files: String,
#[arg(short = 'z')]
nul: bool,
#[arg(short, long)]
short: bool,
#[arg(long, num_args = 0..=1, default_missing_value = "v1", require_equals = true)]
porcelain: Option<String>,
#[arg(short = 'b', long)]
branch: bool,
},
Add {
#[arg(short = 'A', long)]
all: bool,
paths: Vec<String>,
},
Rm {
#[arg(long)]
cached: bool,
paths: Vec<String>,
},
Commit {
#[arg(short = 'm')]
message: Option<String>,
#[arg(short = 'F', conflicts_with = "message")]
file: Option<String>,
#[arg(long)]
amend: bool,
#[arg(long)]
no_edit: bool,
#[arg(short, long)]
all: bool,
#[arg(long)]
allow_empty: bool,
/// Accepted for compatibility. This git never signs commits.
#[arg(long)]
no_gpg_sign: bool,
},
Log {
#[arg(long)]
name_only: bool,
#[arg(long)]
no_renames: bool,
#[arg(long)]
diff_merges: Option<String>,
#[arg(long)]
oneline: bool,
#[arg(short = 'n')]
limit: Option<usize>,
#[arg(long)]
format: Option<String>,
#[arg(long)]
pretty: Option<String>,
#[arg(short = 'p')]
patch: bool,
#[arg(long)]
stat: bool,
revision: Option<String>,
#[arg(last = true)]
paths: Vec<String>,
},
Diff {
#[arg(long)]
numstat: bool,
#[arg(long)]
no_renames: bool,
#[arg(long)]
diff_filter: Option<String>,
#[arg(short = 'z')]
nul: bool,
#[arg(long, alias = "staged")]
cached: bool,
#[arg(long)]
stat: bool,
#[arg(long)]
name_only: bool,
#[arg(long)]
name_status: bool,
revisions: Vec<String>,
#[arg(last = true)]
paths: Vec<String>,
},
Branch {
#[arg(long)]
list: bool,
#[arg(long)]
show_current: bool,
#[arg(short = 'a')]
all: bool,
#[arg(short = 'd')]
delete: bool,
#[arg(short = 'D')]
force_delete: bool,
#[arg(short = 'm')]
rename: bool,
name: Option<String>,
start: Option<String>,
},
Switch {
#[arg(short = 'c')]
create: bool,
target: String,
},
Checkout {
#[arg(long)]
detach: bool,
#[arg(long, short = 'f')]
force: bool,
#[arg(short = 'b')]
create: bool,
target: Option<String>,
paths: Vec<String>,
},
Merge {
#[arg(long)]
no_edit: bool,
#[arg(long)]
ff_only: bool,
#[arg(long)]
no_ff: bool,
#[arg(long)]
no_commit: bool,
#[arg(long)]
abort: bool,
#[arg(short = 'm')]
message: Option<String>,
revision: Option<String>,
},
Rebase {
#[arg(long)]
abort: bool,
#[arg(long = "continue")]
resume: bool,
#[arg(long)]
skip: bool,
upstream: Option<String>,
},
Worktree {
#[command(subcommand)]
command: Worktree,
},
LsFiles {
#[arg(long)]
others: bool,
#[arg(long)]
exclude_standard: bool,
#[arg(short = 'z')]
nul: bool,
},
Reset {
#[arg(long, conflicts_with_all = ["hard", "mixed"])]
soft: bool,
#[arg(long, conflicts_with = "mixed")]
hard: bool,
#[arg(long)]
mixed: bool,
revision: Option<String>,
paths: Vec<String>,
},
Restore {
#[arg(long)]
staged: bool,
paths: Vec<String>,
},
Stash {
#[arg(short = 'm', long)]
message: Option<String>,
command: Option<String>,
entry: Option<String>,
},
Tag {
#[arg(short = 'a')]
annotated: bool,
#[arg(short = 'm')]
message: Option<String>,
#[arg(short = 'l', long)]
list: bool,
#[arg(short = 'd')]
delete: bool,
name: Option<String>,
revision: Option<String>,
},
Remote {
#[arg(short = 'v', long)]
verbose: bool,
command: Option<String>,
name: Option<String>,
url: Option<String>,
},
CherryPick {
#[arg(long = "continue")]
resume: bool,
#[arg(long)]
abort: bool,
revision: Option<String>,
},
}
#[derive(Subcommand)]
enum Worktree {
Prune,
List {
#[arg(long)]
porcelain: bool,
},
Add {
#[arg(long)]
detach: bool,
#[arg(short = 'b')]
branch: Option<String>,
path: String,
revision: Option<String>,
},
Remove {
#[arg(long)]
force: bool,
path: String,
},
}
fn required(value: Option<String>, what: &str) -> Result<String> {
value.ok_or_else(|| error(what, "argument required"))
}
pub fn run(cli: Cli) -> Result<i32> {
let quiet = cli.quiet;
let count = std::env::var("GIT_CONFIG_COUNT")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
for (i, value) in cli.config.iter().enumerate() {
let (key, value) = value.split_once('=').unwrap_or((value, "true"));
// WASI has one thread. Supply Git's per-command configuration before discovery.
unsafe {
std::env::set_var(format!("GIT_CONFIG_KEY_{}", count + i), key);
std::env::set_var(format!("GIT_CONFIG_VALUE_{}", count + i), value);
}
}
if !cli.config.is_empty() {
unsafe {
std::env::set_var("GIT_CONFIG_COUNT", (count + cli.config.len()).to_string());
}
}
if let Some(dir) = cli.directory {
std::env::set_current_dir(dir).map_err(|e| error("chdir", e))?;
}
if let Command::Init {
bare,
initial_branch,
path,
} = cli.command
{
let path = path.unwrap_or_else(|| ".".into());
let existing = std::path::Path::new(&path)
.join(if bare { "HEAD" } else { ".git/HEAD" })
.exists();
let repo = if existing {
repo::open(&path)
} else {
let repo = if bare {
gix::init_bare(&path)
} else {
gix::init(&path)
}
.map_err(|e| error("init", e))?;
std::fs::write(
repo.git_dir().join("HEAD"),
format!("ref: refs/heads/{initial_branch}\n"),
)
.map_err(|e| error("init HEAD", e))?;
Ok(repo)
}?;
if !quiet {
println!(
"{} Git repository in {}/",
if existing {
"Reinitialized existing"
} else {
"Initialized empty"
},
repo.git_dir()
.canonicalize()
.map_err(|e| error("init", e))?
.display()
);
}
return Ok(0);
}
if let Command::Clone { url, path } = cli.command {
let path = path.unwrap_or_else(|| {
url.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or("repo")
.trim_end_matches(".git")
.to_owned()
});
network::clone(&url, &path)?;
return Ok(0);
}
let discovered = gix::discover(".").map_err(|e| error("repository", e))?;
let root = discovered
.workdir()
.unwrap_or(discovered.git_dir())
.to_owned();
std::env::set_current_dir(&root).map_err(|e| error("chdir", e))?;
let path = root.to_string_lossy().into_owned();
let repository = repo::open(&path)?;
match cli.command {
Command::Reset {
soft,
hard,
revision,
paths,
..
} => local::reset(
&repository,
revision.as_deref().unwrap_or("HEAD"),
&paths,
soft,
hard,
quiet,
)?,
Command::Restore { staged, paths } => local::restore(
&repository,
if staged { Some("HEAD") } else { None },
&paths,
staged,
)?,
Command::Stash {
command,
message,
entry,
} => {
return stash::run(
&repository,
command.as_deref().unwrap_or("push"),
message,
entry,
quiet,
);
}
Command::Tag {
annotated,
message,
delete,
name,
revision,
..
} => local::tag(&repository, name, revision, annotated, message, delete)?,
Command::Remote {
verbose,
command,
name,
url,
} => match command.as_deref() {
Some("add") | Some("set-url") => {
remotes::set(
&repository,
&required(name, "remote name")?,
&required(url, "remote URL")?,
command.as_deref() == Some("add"),
)?;
}
Some("remove") => remotes::remove(&repository, &required(name, "remote name")?)?,
Some("get-url") => {
let name = required(name, "remote name")?;
let remote = remotes::list(&repository)?
.into_iter()
.find(|r| r.name == name)
.ok_or_else(|| error("remote", "remote does not exist"))?;
println!("{}", remote.url.unwrap_or_default());
}
None => {
for remote in remotes::list(&repository)? {
if verbose {
let url = remote.url.unwrap_or_default();
println!(
"{}\t{} (fetch)\n{}\t{} (push)",
remote.name, url, remote.name, url
);
} else {
println!("{}", remote.name);
}
}
}
_ => {
eprintln!("usage: git remote [add|remove|set-url|get-url]");
return Ok(129);
}
},
Command::CherryPick {
resume,
abort,
revision,
} => return cherry_pick::run(&repository, revision, resume, abort, quiet),
Command::MergeBase { a, b, is_ancestor } => {
return plumbing::merge_base(&repository, &a, &b, is_ancestor);
}
Command::RevList { count, revisions } => {
let mut included = std::collections::BTreeSet::new();
let mut excluded = std::collections::BTreeSet::new();
for revision in revisions {
let (tip, hidden) = plumbing::range(&repository, &revision)?;
included.extend(plumbing::ancestors(&repository, tip)?);
excluded.extend(hidden);
}
included.retain(|id| !excluded.contains(id));
if count {
println!("{}", included.len());
} else {
for id in included {
println!("{id}");
}
}
}
Command::SymbolicRef { short, name } => {
let reference = repository
.find_reference(name.as_str())
.map_err(|e| error("symbolic-ref", e))?;
if let gix::refs::TargetRef::Symbolic(target) = reference.target() {
println!(
"{}",
if short {
target.shorten().to_string()
} else {
target.as_bstr().to_string()
}
);
} else {
if !quiet {
eprintln!("fatal: ref {name} is not a symbolic ref");
}
return Ok(if quiet { 1 } else { 128 });
}
}
Command::UpdateRef { name, new, old } => {
plumbing::update_ref(&repository, &name, &new, old.as_deref())?
}
Command::CheckIgnore { no_index, paths } => {
return plumbing::check_ignore(&repository, &paths, no_index);
}
Command::Clean { force, directories } => {
if !force {
return Err(error("clean", "requires -f"));
}
plumbing::clean(&repository, directories, quiet)?;
}
Command::LsFiles {
others,
exclude_standard: _,
nul,
} => {
if others {
for path in changes::status(&repository)?.untracked {
print!("{path}{}", if nul { "\0" } else { "\n" });
}
return Ok(0);
}
let index = repository
.index_or_empty()
.map_err(|e| error("ls-files", e))?;
for entry in index.entries() {
print!("{}{}", entry.path(&index), if nul { "\0" } else { "\n" });
}
}
Command::Config { key, value, .. } => {
let file = repository.common_dir().join("config");
if let Some(value) = value {
let mut config = gix::config::File::from_path_no_includes(
file.clone(),
gix::config::Source::Local,
)
.map_err(|e| error("config", e))?;
config
.set_raw_value(&key, value.as_bytes().as_bstr())
.map_err(|e| error("config", e))?;
let mut output = std::fs::File::create(file).map_err(|e| error("config", e))?;
config
.write_to(&mut output)
.map_err(|e| error("config", e))?;
} else if let Some(value) = repository.config_snapshot().string(&key) {
println!("{value}");
} else {
return Ok(1);
}
}
Command::RevParse {
verify,
show_toplevel,
git_dir,
abbrev_ref,
short,
revisions,
} => {
if show_toplevel {
println!("{}", root.display());
} else if git_dir {
println!(".git");
} else if abbrev_ref {
println!(
"{}",
head_info(&repository)?
.branch
.unwrap_or_else(|| "HEAD".into())
.trim_start_matches("refs/heads/")
);
} else {
for revision in revisions {
let id = match repository.rev_parse_single(revision.as_str()) {
Ok(id) => id.to_string(),
Err(_) if verify && quiet => return Ok(1),
Err(e) => return Err(error("revision", e)),
};
println!("{}", if short { &id[..7] } else { &id });
}
}
}
Command::Add { all, mut paths } => {
let all = all || paths.iter().any(|p| p == ".");
paths.retain(|p| p != ".");
staging::add(
&repository,
staging::AddParams {
repo: path,
paths,
all: Some(all),
},
)?;
}
Command::Rm { cached, paths } => {
let result = staging::rm(
&repository,
staging::RmParams {
repo: path,
paths,
keep_file: Some(cached),
},
)?;
for path in result.paths {
println!("rm '{path}'");
}
}
Command::Commit {
message,
file,
amend,
no_edit,
all,
allow_empty,
..
} => {
let before = head_info(&repository)?;
let message = if let Some(file) = file {
std::fs::read_to_string(file).map_err(|e| error("commit message", e))?
} else if let Some(message) = message {
message
} else if amend && no_edit {
repository
.head_commit()
.map_err(|e| error("amend", e))?
.decode()
.map_err(|e| error("amend message", e))?
.message
.to_str_lossy()
.into_owned()
} else {
eprintln!("usage: git commit -m <message> | -F <file> | --amend --no-edit");
return Ok(129);
};
let message = plumbing::trailer(&message);
if !amend && !allow_empty && merge::pending_parent(&repository)?.is_none() {
let status = changes::status(&repository)?;
if status.staged.is_empty()
&& (!all || status.unstaged.is_empty())
&& status.conflicted.is_empty()
{
output::long_status(&status);
return Ok(1);
}
}
let result = staging::commit(
&repository,
staging::CommitParams {
repo: path.clone(),
message: format!("{message}\n"),
author_name: None,
author_email: None,
all: Some(all),
amend: Some(amend),
allow_empty: Some(allow_empty),
},
)?;
if quiet {
return Ok(0);
}
println!(
"[{}{} {}] {}",
before
.branch
.unwrap_or_else(|| "HEAD".into())
.trim_start_matches("refs/heads/"),
if before.unborn { " (root-commit)" } else { "" },
&result.id[..7],
message.lines().next().unwrap_or("")
);
if amend {
output::author_date(
&repository
.find_commit(
gix::ObjectId::from_hex(result.id.as_bytes())
.map_err(|e| error("amend", e))?,
)
.map_err(|e| error("amend", e))?,
)?;
}
let old = match if amend {
repository
.find_commit(
gix::ObjectId::from_hex(result.id.as_bytes())
.map_err(|e| error("amend", e))?,
)
.map_err(|e| error("amend", e))?
.parent_ids()
.next()
.map(|id| id.to_string())
} else {
before.id
} {
Some(id) => id,
None => repository
.write_object(&gix::objs::Tree { entries: vec![] })
.map_err(|e| error("commit summary", e))?
.to_string(),
};
output::commit_stat(&changes::diff(changes::DiffParams {
repo: path,
from: Some(old),
to: Some(result.id),
staged: None,
path: None,
context: None,
})?);
}
Command::Status {
untracked_files,
nul,
short,
porcelain,
branch,
} => {
let mut status = changes::refresh_status(&repository)?;
let index = repository
.index_or_empty()
.map_err(|e| error("status index", e))?;
let tracked: Vec<_> = index
.entries()
.iter()
.map(|e| e.path(&index).to_str_lossy().into_owned())
.collect();
let mut untracked = std::collections::BTreeSet::new();
for path in status.untracked {
if untracked_files == "no" {
continue;
}
if untracked_files == "all" {
untracked.insert(path);
continue;
}
let collapsed = path
.match_indices('/')
.map(|(i, _)| &path[..=i])
.find(|prefix| !tracked.iter().any(|t| t.starts_with(*prefix)))
.unwrap_or(&path);
untracked.insert(collapsed.to_owned());
}
status.untracked = untracked.into_iter().collect();
if !short && porcelain.is_none() {
output::long_status(&status);
return Ok(0);
}
if branch {
println!(
"## {}",
if status.head.detached {
"HEAD (no branch)".into()
} else {
let name = status
.head
.branch
.as_deref()
.unwrap_or("HEAD")
.trim_start_matches("refs/heads/");
if status.head.unborn {
format!("No commits yet on {name}")
} else {
name.to_owned()
}
}
);
}
let mut rows = std::collections::BTreeMap::new();
let mut sources = std::collections::BTreeMap::new();
for c in status.staged {
if let Some(source) = c.old_path {
sources.insert(c.path.clone(), source);
}
rows.entry(c.path).or_insert([' '; 2])[0] = code(&c.status);
}
for c in status.unstaged {
rows.entry(c.path).or_insert([' '; 2])[1] = code(&c.status);
}
for p in status.untracked {
rows.insert(p, ['?', '?']);
}
for p in status.conflicted {
rows.insert(p, ['U', 'U']);
}
for untracked in [false, true] {
for (p, c) in &rows {
if (c == &['?', '?']) == untracked {
if let Some(source) = sources.get(p) {
if nul {
print!("{}{} {p}\0{source}\0", c[0], c[1]);
} else {
println!("{}{} {source} -> {p}", c[0], c[1]);
}
} else {
print!("{}{} {p}{}", c[0], c[1], if nul { "\0" } else { "\n" });
}
}
}
}
}
Command::Diff {
numstat,
no_renames,
diff_filter,
nul,
cached,
stat,
name_only,
name_status,
revisions,
paths,
} => {
let (from, to) = if revisions.len() == 1 && revisions[0].contains("..") {
let (a, b) = revisions[0].split_once("..").unwrap();
(Some(a.into()), Some(b.into()))
} else {
(revisions.first().cloned(), revisions.get(1).cloned())
};
if diff_filter.as_deref() == Some("U") {
for p in changes::status(&repository)?.conflicted {
print!("{p}{}", if nul { "\0" } else { "\n" });
}
return Ok(0);
}
let diffs = changes::diff_with_renames(
changes::DiffParams {
repo: path,
from,
to,
staged: Some(cached),
path: paths.first().cloned(),
context: None,
},
!no_renames,
)?;
if name_only || name_status {
for diff in diffs {
if name_status {
print!(
"{}{}{}{}",
code(&diff.status),
if nul { "\0" } else { "\t" },
diff.path,
if nul { "\0" } else { "\n" }
);
} else {
print!("{}{}", diff.path, if nul { "\0" } else { "\n" });
}
}
} else if numstat {
for diff in &diffs {
output::numstat(diff, nul);
}
} else if stat {
output::stat(&diffs, false);
} else {
for diff in diffs {
print!("{}", diff.patch);
}
}
}
Command::Log {
name_only,
no_renames,
diff_merges,
oneline,
limit,
format,
pretty,
patch,
stat,
revision,
paths,
} => {
let limit = limit.unwrap_or(usize::MAX);
let (tip, excluded) =
plumbing::range(&repository, revision.as_deref().unwrap_or("HEAD"))?;
let commit = repository.find_commit(tip).map_err(|e| error("log", e))?;
let ids = if let Some(path) = paths.first() {
history::paths(&repository, commit.id, path)?
} else {
commit
.ancestors()
.use_commit_graph(false)
.sorting(gix::revision::walk::Sorting::ByCommitTime(
Default::default(),
))
.all()
.map_err(|e| error("log", e))?
.filter(|info| {
info.as_ref()
.map_or(true, |info| !excluded.contains(&info.id))
})
.take(limit)
.map(|info| info.map(|info| info.id).map_err(|e| error("log", e)))
.collect::<Result<Vec<_>>>()?
};
let mut first = true;
for id in ids
.into_iter()
.filter(|id| !excluded.contains(id))
.take(limit)
{
let c = repository.find_commit(id).map_err(|e| error("log", e))?;
let decoded = c.decode().map_err(|e| error("log", e))?;
let message = decoded.message.to_str_lossy();
let diffs = if (patch || stat || name_only || !paths.is_empty())
&& (decoded.parents.len() <= 1
|| diff_merges.as_deref() == Some("first-parent")
|| !name_only)
{
let old = match decoded.parents.first() {
Some(id) => id.to_string(),
None => repository
.write_object(&gix::objs::Tree { entries: vec![] })
.map_err(|e| error("log tree", e))?
.to_string(),
};
changes::diff_with_renames(
changes::DiffParams {
repo: path.clone(),
from: Some(old),
to: Some(c.id.to_string()),
staged: None,
path: paths.first().cloned(),
context: None,
},
!no_renames,
)?
} else {
Vec::new()
};
if let Some(template) = format.as_deref().or(pretty
.as_deref()
.map(|s| s.strip_prefix("format:").unwrap_or(s)))
{
let author = c.author().map_err(|e| error("log author", e))?;
let date = author
.time()
.map_err(|e| error("log date", e))?
.format_or_unix(gix::date::time::format::DEFAULT);
let mut labels = Vec::new();
for reference in repository
.references()
.map_err(|e| error("log refs", e))?
.all()
.map_err(|e| error("log refs", e))?
{
let reference = reference.map_err(|e| error("log refs", e))?;
let name = reference.name().as_bstr().to_str_lossy();
if repository
.rev_parse_single(name.as_ref())
.map(|id| id.to_string() == c.id.to_string())
.unwrap_or(false)
{
let short = reference.name().shorten().to_string();
labels.push(
if head_info(&repository)?.branch.as_deref() == Some(name.as_ref())
{
format!("HEAD -> {short}")
} else {
short
},
);
}
}
let decorations = if labels.is_empty() {
String::new()
} else {
format!(" ({})", labels.join(", "))
};
let text = plumbing::format(template, &message)
.replace("%H", &c.id.to_string())
.replace("%h", &c.id.to_string()[..7])
.replace("%s", message.lines().next().unwrap_or(""))
.replace("%an", &author.name.to_string())
.replace("%ae", &author.email.to_string())
.replace("%ad", &date)
.replace("%d", &decorations);
if pretty.is_some() {
if !first {
println!();
}
print!("{text}");
} else {
println!("{text}");
}
first = false;
} else if oneline {
println!(
"{} {}",
&c.id.to_string()[..7],
message.lines().next().unwrap_or("")
);
} else {
if !first {
println!();
}
output::commit(&c)?;
first = false;
}
if name_only && !diffs.is_empty() {
println!();
for diff in &diffs {
println!("{}", diff.path);
}
}
if patch || stat {
println!();
if stat {
output::stat(&diffs, false);
}
if patch {
for diff in diffs {
print!("{}", diff.patch);
}
}
}
}
}
Command::Branch {
list,
delete,
force_delete,
show_current,
all,
rename,
name,
start,
} => {
if rename {
let new = required(start.clone().or(name.clone()), "new branch name")?;
local::rename_branch(
&repository,
if start.is_some() {
name.as_deref()
} else {
None
},
&new,
)?;
return Ok(0);
}
if show_current {
if let Some(branch) = head_info(&repository)?.branch {
println!("{}", branch.trim_start_matches("refs/heads/"));
}
return Ok(0);
}
if let Some(name) = name.as_ref().filter(|_| !list) {
let name = name.clone();
if delete || force_delete {
let id = branches::resolve(&repository, &name)?.to_string();
branches::delete(
&repository,
branches::BranchDeleteParams {
repo: path,
name: name.clone(),
force: Some(force_delete),
},
)?;
if !quiet {
println!("Deleted branch {name} (was {}).", &id[..7]);
}
} else {
branches::create(
&repository,
branches::BranchCreateParams {
repo: path,
name,
from: start,
checkout: Some(false),
},
)?;
}
} else {
let pattern = name.as_deref();
let head = head_info(&repository)?;
for branch in repository
.references()
.map_err(|e| error("branch", e))?
.all()
.map_err(|e| error("branch", e))?
{
let branch = branch.map_err(|e| error("branch", e))?;
let name = branch.name().as_bstr().to_str_lossy();
if !name.starts_with("refs/heads/")
&& !(all && name.starts_with("refs/remotes/"))
{
continue;
}
if list
&& pattern
.is_some_and(|pattern| branch.name().shorten().to_string() != pattern)
{
continue;
}
println!(
"{} {}",
if head.branch.as_deref()
== Some(branch.name().as_bstr().to_str_lossy().as_ref())
{
"*"
} else {
" "
},
if name.starts_with("refs/remotes/") {
format!("remotes/{}", name.trim_start_matches("refs/remotes/"))
} else {
branch.name().shorten().to_string()
}
);
}
}
}
Command::Checkout {
detach,
force,
create,
target,
mut paths,
} => {
if !create
&& (!paths.is_empty()
|| target.as_ref().is_some_and(|t| {
root.join(t).is_file()
&& repository
.try_find_reference(format!("refs/heads/{t}").as_str())
.ok()
.flatten()
.is_none()
}))
{
if let Some(target) = target {
paths.insert(0, target);
}
local::restore(&repository, None, &paths, false)?;
} else {
branches::checkout(
&repository,
branches::CheckoutParams {
repo: path,
target: if detach {
branches::resolve(&repository, &required(target, "checkout target")?)?
.to_string()
} else {
required(target, "checkout target")?
},
create: Some(create),
force: Some(force),
},
)?;
}
}
Command::Switch { create, target } => {
branches::checkout(
&repository,
branches::CheckoutParams {
repo: path,
target,
create: Some(create),
force: None,
},
)?;
}
Command::Merge {
no_edit: _,
ff_only,
no_ff,
no_commit,
abort,
message,
revision,
} => {
let old = head_info(&repository)?.id;
let message =
message.or_else(|| revision.as_ref().map(|r| format!("Merge branch '{r}'\n")));
let r = if abort {
merge::abort(&repository)?
} else {
merge::merge(
&repository,
merge::MergeParams {
repo: path.clone(),
rev: required(revision, "merge")?,
message: message.map(|m| format!("{}\n", m.trim_end())),
ff: Some(if ff_only {
merge::FastForward::Only
} else if no_ff {
merge::FastForward::Never
} else {
merge::FastForward::Allow
}),
no_commit: Some(no_commit),
},
)?
};
if r.already_up_to_date && !quiet {
println!("Already up to date.");
}
output::conflicts(&r.conflicts, true);
if quiet {
return Ok(if r.conflicts.is_empty() { 0 } else { 1 });
}
if !r.conflicts.is_empty() {
return Ok(1);
}
if !abort && !r.already_up_to_date && !r.pending {
let new = head_info(&repo::open(&path)?)?.id.unwrap();
if let Some(old) = old {
if r.fast_forward {
println!("Updating {}..{}\nFast-forward", &old[..7], &new[..7]);
} else {
println!("Merge made by the 'ort' strategy.");
}
output::stat(
&changes::diff(changes::DiffParams {
repo: path,
from: Some(old),
to: Some(new),
staged: None,
path: None,
context: None,
})?,
true,
);
}
}
}
Command::Rebase {
abort,
resume,
skip,
upstream,
} => {
let r = if abort {
rebase::abort(&repository)?
} else if resume || skip {
rebase::resume(&repository, skip)?
} else {
rebase::start(
&repository,
rebase::RebaseParams {
repo: path.clone(),
onto: Some(required(upstream, "rebase")?),
branch: None,
},
)?
};
if quiet {
output::conflicts(&r.conflicts, false);
return Ok(if r.conflicts.is_empty() { 0 } else { 1 });
}
if let Some(id) = &r.resolved {
let commit = repository
.find_commit(
gix::ObjectId::from_hex(id.as_bytes())
.map_err(|e| error("rebase output", e))?,
)
.map_err(|e| error("rebase output", e))?;
let decoded = commit.decode().map_err(|e| error("rebase output", e))?;
println!(
"[detached HEAD {}] {}",
&id[..7],
decoded.message.to_str_lossy().lines().next().unwrap_or("")
);
output::commit_stat(&changes::diff(changes::DiffParams {
repo: path,
from: Some(decoded.parents[0].to_string()),
to: Some(id.clone()),
staged: None,
path: None,
context: None,
})?);
}
output::conflicts(&r.conflicts, false);
if !r.conflicts.is_empty() {
return Ok(1);
}
}
Command::Worktree { command } => match command {
Worktree::Prune => worktrees::prune(&repository)?,
Worktree::List { porcelain } => {
let common = repository
.common_dir()
.canonicalize()
.map_err(|e| error("worktree list", e))?;
let mut paths = vec![
common
.parent()
.ok_or_else(|| error("worktree list", "no worktree"))?
.to_owned(),
];
if let Ok(admins) = std::fs::read_dir(common.join("worktrees")) {
let mut linked = Vec::new();
for admin in admins {
let admin = admin.map_err(|e| error("worktree list", e))?;
let gitfile = std::fs::read_to_string(admin.path().join("gitdir"))
.map_err(|e| error("worktree list", e))?;
if let Some(parent) = std::path::Path::new(gitfile.trim()).parent() {
linked.push(parent.to_owned());
}
}
linked.sort();
paths.extend(linked);
}
let width = paths
.iter()
.map(|p| p.to_string_lossy().len())
.max()
.unwrap_or(0)
+ 2;
for p in paths {
let p = p
.canonicalize()
.map_err(|e| error("worktree list path", e))?;
let linked = repo::open(&p.to_string_lossy())?;
let head = head_info(&linked)?;
let id = head.id.unwrap_or_else(|| "0".repeat(40));
if porcelain {
println!("worktree {}\nHEAD {}", p.display(), id);
if let Some(branch) = head.branch {
println!("branch {branch}");
} else {
println!("detached");
}
println!();
} else {
let branch = head
.branch
.map(|b| format!("[{}]", b.trim_start_matches("refs/heads/")))
.unwrap_or_else(|| "(detached HEAD)".into());
println!("{:width$}{} {}", p.display().to_string(), &id[..7], branch);
}
}
}
Worktree::Add {
detach,
branch,
path: target,
revision,
} => {
let branch = branch.unwrap_or_else(|| {
std::path::Path::new(&target)
.file_name()
.unwrap()
.to_string_lossy()
.into_owned()
});
let info = worktrees::add(
&repository,
worktrees::AddParams {
repo: path,
path: std::path::absolute(target)
.map_err(|e| error("worktree path", e))?
.to_string_lossy()
.into_owned(),
branch,
detached: Some(detach),
from: revision,
adopt: None,
},
)?;
let linked = repo::open(&info.path)?;
let commit = linked
.head_commit()
.map_err(|e| error("worktree HEAD", e))?;
let decoded = commit.decode().map_err(|e| error("worktree HEAD", e))?;
println!(
"HEAD is now at {} {}",
&commit.id.to_string()[..7],
decoded.message.to_str_lossy().lines().next().unwrap_or("")
);
}
Worktree::Remove { force, path } => {
let target = std::path::absolute(path)
.map_err(|e| error("worktree remove", e))?
.to_string_lossy()
.into_owned();
worktrees::remove(
&repo::open(&target)?,
worktrees::RemoveParams {
repo: target,
force: Some(force),
},
)?;
}
},
Command::Show { revision, stat } => {
let object = repository
.rev_parse_single(revision.as_deref().unwrap_or("HEAD"))
.map_err(|e| error("show", e))?
.object()
.map_err(|e| error("show", e))?;
if object.kind == gix::objs::Kind::Blob {
use std::io::Write;
std::io::stdout()
.write_all(&object.data)
.map_err(|e| error("show", e))?;
} else {
let commit = object.peel_to_commit().map_err(|e| error("show", e))?;
let merge_commit = commit.parent_ids().count() > 1;
if merge_commit && !stat {
let mut common: Option<std::collections::BTreeSet<String>> = None;
for parent in commit.parent_ids() {
let names = changes::diff(changes::DiffParams {
repo: path.clone(),
from: Some(parent.to_string()),
to: Some(commit.id.to_string()),
staged: None,
path: None,
context: None,
})?
.into_iter()
.map(|d| d.path)
.collect::<std::collections::BTreeSet<_>>();
common = Some(match common {
None => names,
Some(paths) => paths.intersection(&names).cloned().collect(),
});
}
if common.is_some_and(|paths| !paths.is_empty()) {
eprintln!(
"usage: git show: combined merge diffs are not supported (see README.md)"
);
return Ok(129);
}
}
output::commit(&commit)?;
println!();
if merge_commit && !stat {
return Ok(0);
}
let old = match commit.parent_ids().next() {
Some(id) => id.to_string(),
None => repository
.write_object(&gix::objs::Tree { entries: vec![] })
.map_err(|e| error("show", e))?
.to_string(),
};
let diffs = changes::diff(changes::DiffParams {
repo: path,
from: Some(old),
to: Some(commit.id.to_string()),
staged: None,
path: None,
context: None,
})?;
if stat {
output::stat(&diffs, false);
} else {
for diff in diffs {
print!("{}", diff.patch);
}
}
}
}
Command::Fetch { remote, revision } => network::fetch(&path, &remote, revision.as_deref())?,
Command::Push {
set_upstream,
force,
remote,
refspec,
} => {
push::push(push::PushParams {
repo: path,
remote: Some(remote),
branch: None,
refspec,
force: Some(force),
set_upstream: Some(set_upstream),
delete: None,
})?;
}
Command::Init { .. } | Command::Clone { .. } => unreachable!(),
}
Ok(0)
}
fn code(k: &changes::Kind) -> char {
match k {
changes::Kind::Added => 'A',
changes::Kind::Modified => 'M',
changes::Kind::Deleted => 'D',
changes::Kind::Renamed => 'R',
}
}