git: match native agent workflows

This commit is contained in:
2026-11-06 19:07:28 +00:00
parent b5462cc366
commit 6daa53cca4
11 changed files with 1641 additions and 75 deletions
+587 -64
View File
@@ -55,18 +55,28 @@ fn identity(name: Option<String>, email: Option<String>) -> Result<Identity> {
.or_else(|| config.string("user.email").map(|s| s.to_string())),
})
}
mod cherry_pick;
mod history;
mod local;
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 {
@@ -96,6 +106,8 @@ enum Command {
Config {
#[arg(long)]
local: bool,
#[arg(long)]
get: bool,
key: String,
value: Option<String>,
},
@@ -106,13 +118,17 @@ enum Command {
git_dir: bool,
#[arg(long)]
abbrev_ref: bool,
revision: Option<String>,
#[arg(long)]
short: bool,
revisions: Vec<String>,
},
Status {
#[arg(short, long)]
short: bool,
#[arg(long)]
porcelain: 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)]
@@ -126,7 +142,13 @@ enum Command {
},
Commit {
#[arg(short = 'm')]
message: String,
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)]
@@ -140,19 +162,42 @@ enum Command {
oneline: bool,
#[arg(short = 'n', default_value = "50")]
limit: 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, 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)]
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>,
},
@@ -164,7 +209,8 @@ enum Command {
Checkout {
#[arg(short = 'b')]
create: bool,
target: String,
target: Option<String>,
paths: Vec<String>,
},
Merge {
#[arg(long)]
@@ -192,6 +238,54 @@ enum Command {
#[command(subcommand)]
command: Worktree,
},
LsFiles,
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 {
@@ -215,6 +309,24 @@ 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))?;
}
@@ -225,24 +337,39 @@ pub fn run(cli: Cli) -> Result<i32> {
} = cli.command
{
let path = path.unwrap_or_else(|| ".".into());
let repo = if bare {
gix::init_bare(&path)
let existing = std::path::Path::new(&path)
.join(if bare { "HEAD" } else { ".git/HEAD" })
.exists();
let repo = if existing {
repo::open(&path)
} else {
gix::init(&path)
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()
);
}
.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))?;
println!(
"Initialized empty Git repository in {}/",
repo.git_dir()
.canonicalize()
.map_err(|e| error("init", e))?
.display()
);
return Ok(0);
}
if let Command::Clone { url, path } = cli.command {
@@ -266,6 +393,101 @@ pub fn run(cli: Cli) -> Result<i32> {
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::LsFiles => {
let index = repository
.index_or_empty()
.map_err(|e| error("ls-files", e))?;
for entry in index.entries() {
println!("{}", entry.path(&index));
}
}
Command::Config { key, value, .. } => {
let file = repository.common_dir().join("config");
if let Some(value) = value {
@@ -291,7 +513,8 @@ pub fn run(cli: Cli) -> Result<i32> {
show_toplevel,
git_dir,
abbrev_ref,
revision,
short,
revisions,
} => {
if show_toplevel {
println!("{}", root.display());
@@ -306,12 +529,13 @@ pub fn run(cli: Cli) -> Result<i32> {
.trim_start_matches("refs/heads/")
);
} else {
println!(
"{}",
repository
.rev_parse_single(required(revision, "revision")?.as_str())
for revision in revisions {
let id = repository
.rev_parse_single(revision.as_str())
.map_err(|e| error("revision", e))?
);
.to_string();
println!("{}", if short { &id[..7] } else { &id });
}
}
}
Command::Add { all, mut paths } => {
@@ -341,12 +565,33 @@ pub fn run(cli: Cli) -> Result<i32> {
}
Command::Commit {
message,
file,
amend,
no_edit,
all,
allow_empty,
..
} => {
let before = head_info(&repository)?;
if !allow_empty && merge::pending_parent(&repository)?.is_none() {
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 = message.trim_end().to_owned();
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())
@@ -364,10 +609,13 @@ pub fn run(cli: Cli) -> Result<i32> {
author_name: None,
author_email: None,
all: Some(all),
amend: None,
amend: Some(amend),
allow_empty: Some(allow_empty),
},
)?;
if quiet {
return Ok(0);
}
println!(
"[{}{} {}] {}",
before
@@ -376,9 +624,31 @@ pub fn run(cli: Cli) -> Result<i32> {
.trim_start_matches("refs/heads/"),
if before.unborn { " (root-commit)" } else { "" },
&result.id[..7],
message
message.lines().next().unwrap_or("")
);
let old = match before.id {
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![] })
@@ -394,7 +664,11 @@ pub fn run(cli: Cli) -> Result<i32> {
context: None,
})?);
}
Command::Status { short, porcelain } => {
Command::Status {
short,
porcelain,
branch,
} => {
let mut status = changes::status(&repository)?;
let index = repository
.index_or_empty()
@@ -414,10 +688,30 @@ pub fn run(cli: Cli) -> Result<i32> {
untracked.insert(collapsed.to_owned());
}
status.untracked = untracked.into_iter().collect();
if !short && !porcelain {
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();
for c in status.staged {
rows.entry(c.path).or_insert([' '; 2])[0] = code(&c.status);
@@ -431,20 +725,45 @@ pub fn run(cli: Cli) -> Result<i32> {
for p in status.conflicted {
rows.insert(p, ['U', 'U']);
}
for (p, c) in rows {
println!("{}{} {p}", c[0], c[1]);
for untracked in [false, true] {
for (p, c) in &rows {
if (c == &['?', '?']) == untracked {
println!("{}{} {p}", c[0], c[1]);
}
}
}
}
Command::Diff { cached, stat } => {
Command::Diff {
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())
};
let diffs = changes::diff(changes::DiffParams {
repo: path,
from: None,
to: None,
from,
to,
staged: Some(cached),
path: None,
path: paths.first().cloned(),
context: None,
})?;
if stat {
if name_only || name_status {
for diff in diffs {
if name_status {
println!("{}\t{}", code(&diff.status), diff.path);
} else {
println!("{}", diff.path);
}
}
} else if stat {
output::stat(&diffs, false);
} else {
for diff in diffs {
@@ -455,7 +774,12 @@ pub fn run(cli: Cli) -> Result<i32> {
Command::Log {
oneline,
limit,
format,
pretty,
patch,
stat,
revision,
paths,
} => {
let commit = repository
.rev_parse_single(revision.as_deref().unwrap_or("HEAD"))
@@ -464,24 +788,102 @@ pub fn run(cli: Cli) -> Result<i32> {
.map_err(|e| error("log", e))?
.peel_to_commit()
.map_err(|e| error("log", e))?;
let mut first = true;
for info in commit
.ancestors()
.use_commit_graph(false)
.sorting(gix::revision::walk::Sorting::ByCommitTime(
Default::default(),
))
.all()
.map_err(|e| error("log", e))?
.take(limit)
{
let c = info
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))?
.object()
.map_err(|e| error("log", e))?;
.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().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();
if oneline {
let diffs = if patch || stat || !paths.is_empty() {
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(changes::DiffParams {
repo: path.clone(),
from: Some(old),
to: Some(c.id.to_string()),
staged: None,
path: paths.first().cloned(),
context: None,
})?
} 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 = template
.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],
@@ -494,24 +896,61 @@ pub fn run(cli: Cli) -> Result<i32> {
output::commit(&c)?;
first = false;
}
if patch || stat {
println!();
if stat {
output::stat(&diffs, false);
}
if patch {
for diff in diffs {
print!("{}", diff.patch);
}
}
}
}
}
Command::Branch {
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 {
if delete || force_delete {
let id = branches::resolve(&repository, &name)?.to_string();
branches::delete(
&repository,
branches::BranchDeleteParams {
repo: path,
name,
name: name.clone(),
force: Some(force_delete),
},
)?;
if !quiet {
println!("Deleted branch {name} (was {}).", &id[..7]);
}
} else {
branches::create(
&repository,
@@ -528,10 +967,16 @@ pub fn run(cli: Cli) -> Result<i32> {
for branch in repository
.references()
.map_err(|e| error("branch", e))?
.local_branches()
.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;
}
println!(
"{} {}",
if head.branch.as_deref()
@@ -541,12 +986,48 @@ pub fn run(cli: Cli) -> Result<i32> {
} else {
" "
},
branch.name().shorten()
if name.starts_with("refs/remotes/") {
format!("remotes/{}", name.trim_start_matches("refs/remotes/"))
} else {
branch.name().shorten().to_string()
}
);
}
}
}
Command::Switch { create, target } | Command::Checkout { create, target } => {
Command::Checkout {
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: required(target, "checkout target")?,
create: Some(create),
force: None,
},
)?;
}
}
Command::Switch { create, target } => {
branches::checkout(
&repository,
branches::CheckoutParams {
@@ -588,10 +1069,13 @@ pub fn run(cli: Cli) -> Result<i32> {
},
)?
};
if r.already_up_to_date {
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);
}
@@ -637,6 +1121,10 @@ pub fn run(cli: Cli) -> Result<i32> {
},
)?
};
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(
@@ -766,7 +1254,7 @@ pub fn run(cli: Cli) -> Result<i32> {
)?;
}
},
Command::Show { revision } => {
Command::Show { revision, stat } => {
let object = repository
.rev_parse_single(revision.as_deref().unwrap_or("HEAD"))
.map_err(|e| error("show", e))?
@@ -779,8 +1267,38 @@ pub fn run(cli: Cli) -> Result<i32> {
.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
@@ -788,15 +1306,20 @@ pub fn run(cli: Cli) -> Result<i32> {
.map_err(|e| error("show", e))?
.to_string(),
};
for diff in changes::diff(changes::DiffParams {
let diffs = changes::diff(changes::DiffParams {
repo: path,
from: Some(old),
to: Some(commit.id.to_string()),
staged: None,
path: None,
context: None,
})? {
print!("{}", diff.patch);
})?;
if stat {
output::stat(&diffs, false);
} else {
for diff in diffs {
print!("{}", diff.patch);
}
}
}
}