userland/git: git-compatible CLI on v2's gix code (T10)

Local workflow, smart-HTTP clone/fetch/push and public HTTPS clone/fetch match
native git in stdout and exit codes. Only WASI imports (wasi:http, exit-with-code).
This commit is contained in:
2026-11-06 16:07:57 +00:00
parent 0fae0f9b10
commit 4b27e9afd5
204 changed files with 37736 additions and 0 deletions
+827
View File
@@ -0,0 +1,827 @@
//! 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 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())),
})
}
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>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Show {
revision: Option<String>,
},
Clone {
url: String,
path: Option<String>,
},
Fetch {
#[arg(default_value = "origin")]
remote: 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,
key: String,
value: Option<String>,
},
RevParse {
#[arg(long)]
show_toplevel: bool,
#[arg(long)]
git_dir: bool,
#[arg(long)]
abbrev_ref: bool,
revision: Option<String>,
},
Status {
#[arg(short, long)]
short: bool,
#[arg(long)]
porcelain: bool,
},
Add {
#[arg(short = 'A', long)]
all: bool,
paths: Vec<String>,
},
Rm {
#[arg(long)]
cached: bool,
paths: Vec<String>,
},
Commit {
#[arg(short = 'm')]
message: String,
#[arg(short, long)]
all: bool,
#[arg(long)]
allow_empty: bool,
},
Log {
#[arg(long)]
oneline: bool,
#[arg(short = 'n', default_value = "50")]
limit: usize,
revision: Option<String>,
},
Diff {
#[arg(long, alias = "staged")]
cached: bool,
#[arg(long)]
stat: bool,
},
Branch {
#[arg(short = 'd')]
delete: bool,
#[arg(short = 'D')]
force_delete: bool,
name: Option<String>,
start: Option<String>,
},
Switch {
#[arg(short = 'c')]
create: bool,
target: String,
},
Checkout {
#[arg(short = 'b')]
create: bool,
target: String,
},
Merge {
#[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,
},
}
#[derive(Subcommand)]
enum Worktree {
List {
#[arg(long)]
porcelain: bool,
},
Add {
#[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> {
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 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))?;
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 {
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::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 {
show_toplevel,
git_dir,
abbrev_ref,
revision,
} => {
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 {
println!(
"{}",
repository
.rev_parse_single(required(revision, "revision")?.as_str())
.map_err(|e| error("revision", e))?
);
}
}
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,
all,
allow_empty,
} => {
let before = head_info(&repository)?;
if !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: None,
allow_empty: Some(allow_empty),
},
)?;
println!(
"[{}{} {}] {}",
before
.branch
.unwrap_or_else(|| "HEAD".into())
.trim_start_matches("refs/heads/"),
if before.unborn { " (root-commit)" } else { "" },
&result.id[..7],
message
);
let old = match 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 { short, porcelain } => {
let mut status = changes::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 {
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 {
output::long_status(&status);
return Ok(0);
}
let mut rows = std::collections::BTreeMap::new();
for c in status.staged {
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 (p, c) in rows {
println!("{}{} {p}", c[0], c[1]);
}
}
Command::Diff { cached, stat } => {
let diffs = changes::diff(changes::DiffParams {
repo: path,
from: None,
to: None,
staged: Some(cached),
path: None,
context: None,
})?;
if stat {
output::stat(&diffs, false);
} else {
for diff in diffs {
print!("{}", diff.patch);
}
}
}
Command::Log {
oneline,
limit,
revision,
} => {
let commit = repository
.rev_parse_single(revision.as_deref().unwrap_or("HEAD"))
.map_err(|e| error("log", e))?
.object()
.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
.map_err(|e| error("log", e))?
.object()
.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 {
println!(
"{} {}",
&c.id.to_string()[..7],
message.lines().next().unwrap_or("")
);
} else {
if !first {
println!();
}
output::commit(&c)?;
first = false;
}
}
}
Command::Branch {
delete,
force_delete,
name,
start,
} => {
if let Some(name) = name {
if delete || force_delete {
branches::delete(
&repository,
branches::BranchDeleteParams {
repo: path,
name,
force: Some(force_delete),
},
)?;
} else {
branches::create(
&repository,
branches::BranchCreateParams {
repo: path,
name,
from: start,
checkout: Some(false),
},
)?;
}
} else {
let head = head_info(&repository)?;
for branch in repository
.references()
.map_err(|e| error("branch", e))?
.local_branches()
.map_err(|e| error("branch", e))?
{
let branch = branch.map_err(|e| error("branch", e))?;
println!(
"{} {}",
if head.branch.as_deref()
== Some(branch.name().as_bstr().to_str_lossy().as_ref())
{
"*"
} else {
" "
},
branch.name().shorten()
);
}
}
}
Command::Switch { create, target } | Command::Checkout { create, target } => {
branches::checkout(
&repository,
branches::CheckoutParams {
repo: path,
target,
create: Some(create),
force: None,
},
)?;
}
Command::Merge {
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,
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 {
println!("Already up to date.");
}
output::conflicts(&r.conflicts, true);
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 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::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 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 {
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,
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 } => {
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))?;
output::commit(&commit)?;
println!();
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(),
};
for diff in changes::diff(changes::DiffParams {
repo: path,
from: Some(old),
to: Some(commit.id.to_string()),
staged: None,
path: None,
context: None,
})? {
print!("{}", diff.patch);
}
}
}
Command::Fetch { remote } => network::fetch(&path, &remote)?,
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',
}
}