Merge v3-t16: git matches native agent workflows (T16)

This commit is contained in:
2026-11-06 19:17:17 +00:00
11 changed files with 1641 additions and 75 deletions
+46 -1
View File
@@ -25,7 +25,10 @@ The script checks that the component imports only WASI interfaces.
It creates separate native and WASI repositories under `/tmp/p1-git/`.
It fixes both identities and timestamps.
It compares stdout and exit codes without filtering command output.
Initialization and worktree listings compare exit codes only because their paths differ.
The initialization check normalizes only its repository path.
Worktree listings compare exit codes only because their paths differ.
Mutation checks also compare repository state through native Git on both fixtures.
They compare status, commit IDs, parents, authors, diffs, and references.
The script retains its fixtures for inspection.
`wasmtime run` needs `-S http`, including for local operations.
@@ -34,6 +37,48 @@ The component uses WASI's `exit-with-code`, not a custom host import.
The world host must enable that optional WASI function.
The tests pass explicit `-C` paths.
## Agent command forms
The comparison suite covers per-command `-c key=value` overrides and quiet local commands.
It checks multiple revisions, detached status, branch deletion messages, and merge commit output.
It also checks these forms:
- `status -sb` and `status --porcelain=v1`.
- `log -n`, `--oneline`, `-p`, `--stat`, and one literal path after `--`.
- `log --format` and `--pretty=format:` with `%H %h %s %an %ae %ad %d`.
- `show --stat`, `show HEAD:path`, and merge commits without a combined diff.
- `diff HEAD~1`, `diff A..B`, `--name-only`, `--name-status`, and one literal path after `--`.
- `branch -a`, `-d`, `-D`, `-m`, and `--show-current`.
- `checkout -b`, `switch -c`, and `merge --no-ff`.
- `rev-parse --short HEAD`, `ls-files`, and `config --get`.
- Quiet `init`, `commit`, branch deletion, `checkout`, `switch`, `merge`, and `rebase`.
- `init` in an existing repository without replacement of HEAD or configuration.
- `reset --soft`, `reset --hard`, mixed `reset HEAD~1`, and `reset HEAD <file>`.
- `commit --amend -m`, `commit --amend --no-edit`, and `commit -F`.
- `checkout <file>`, `checkout -- <file>`, `restore <file>`, and `restore --staged`.
- Bare `stash`, `stash push`, `-m`, `list`, `pop`, `apply`, and `drop`.
- Quiet stash push, pop, and drop.
- Lightweight tags, `tag -a -m`, bare `tag`, `--list`, and `-d`.
- `remote -v`, `add`, `remove`, `set-url`, and `get-url`.
- Clean `cherry-pick` and conflict resolution through `--continue` or `--abort`.
The log applies its path filter before its count limit.
Stashes use ordinary Git commits and the `refs/stash` reflog.
Stash accepts only the listed subcommands.
Cherry-pick conflicts use `CHERRY_PICK_HEAD` and `MERGE_MSG`.
### Unsupported forms
These forms return a usage error with exit code 129 and empty stdout.
The suite checks the usage error instead of native Git's successful operation.
- `log --graph --oneline --all`.
- `show` of a merge that changes the same path relative to every parent, which requires a combined diff.
The CLI supports textual diffs and literal paths, not Git's complete pathspec language.
The format options support the placeholders above, not Git's complete pretty-format language.
The suite compares stdout and exit codes. It does not compare stderr text.
## Local smart-HTTP test
Build the native fixture server:
+12
View File
@@ -373,6 +373,18 @@ pub fn diff(p: DiffParams) -> Result<Vec<FileDiff>> {
let repo = repo::open(&p.repo)?;
let (old, new, work) = match (&p.from, &p.to, p.staged.unwrap_or(false)) {
(Some(a), Some(b), false) => (tree(&repo, a)?, tree(&repo, b)?, false),
(Some(a), None, staged) => {
let (index, _) = index(&repo)?;
if staged {
(tree(&repo, a)?, index, false)
} else {
(
tree(&repo, a)?,
worktree(&repo, &index, p.path.as_deref())?,
true,
)
}
}
(None, None, staged) => {
repo::worktree(&repo)?;
let (index, conflicts) = index(&repo)?;
+180
View File
@@ -0,0 +1,180 @@
//! Single-commit cherry-pick uses Git's CHERRY_PICK_HEAD and MERGE_MSG for continuation.
use crate::{Result, branches, checkout, local, output, repo::error, staging};
use gix::bstr::ByteSlice;
pub(crate) fn merge_trees(
repo: &gix::Repository,
base: gix::ObjectId,
ours: gix::ObjectId,
theirs: gix::ObjectId,
current: &str,
other: &str,
) -> Result<(gix::ObjectId, gix::index::State, Vec<String>)> {
let local = crate::merge::merge_repository(repo)?;
let labels = gix::merge::blob::builtin_driver::text::Labels {
ancestor: None,
current: Some(current.into()),
other: Some(other.into()),
};
let mut outcome = local
.merge_trees(
base,
ours,
theirs,
labels,
local
.tree_merge_options()
.map_err(|e| error("merge options", e))?,
)
.map_err(|e| error("merge trees", e))?;
let tree = outcome
.tree
.write()
.map_err(|e| error("merge tree", e))?
.detach();
let mut index = gix::index::State::from_tree(&tree, &repo.objects, Default::default())
.map_err(|e| error("merge index", e))?;
outcome.index_changed_after_applying_conflicts(
&mut index,
Default::default(),
gix::merge::tree::apply_index_entries::RemovalMode::Prune,
);
index.sort_entries();
let conflicts = index
.entries()
.iter()
.filter(|e| e.stage_raw() != 0)
.map(|e| e.path(&index).to_str_lossy().into_owned())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
Ok((tree, index, conflicts))
}
fn clear(repo: &gix::Repository) -> Result<()> {
for name in ["CHERRY_PICK_HEAD", "MERGE_MSG"] {
let path = repo.git_dir().join(name);
if path.exists() {
std::fs::remove_file(path).map_err(|e| error("cherry-pick cleanup", e))?;
}
}
Ok(())
}
pub(crate) fn run(
repo: &gix::Repository,
revision: Option<String>,
resume: bool,
abort: bool,
quiet: bool,
) -> Result<i32> {
if abort {
if !repo.git_dir().join("CHERRY_PICK_HEAD").exists() {
return Err(error("cherry-pick abort", "no cherry-pick in progress"));
}
let old = std::fs::read_to_string(repo.git_dir().join("ORIG_HEAD"))
.map_err(|e| error("cherry-pick abort", e))?;
local::reset(repo, old.trim(), &[], false, true, true)?;
clear(repo)?;
return Ok(0);
}
let id = if resume {
let id = std::fs::read_to_string(repo.git_dir().join("CHERRY_PICK_HEAD"))
.map_err(|e| error("cherry-pick continue", e))?;
branches::resolve(repo, id.trim())?
} else {
crate::merge::ensure_idle(repo)?;
crate::merge::clean(repo)?;
branches::resolve(repo, &crate::required(revision, "cherry-pick revision")?)?
};
let old = branches::resolve(repo, "HEAD")?;
let original = repo
.find_commit(id)
.map_err(|e| error("cherry-pick commit", e))?;
let decoded = original
.decode()
.map_err(|e| error("cherry-pick decode", e))?;
let subject = decoded
.message
.to_str_lossy()
.lines()
.next()
.unwrap_or("")
.to_owned();
let tree = if resume {
staging::tree_from_index(repo, &staging::load(repo)?)?
} else {
let base = match decoded.parents.first() {
Some(id) => local::tree(repo, &id.to_string())?,
None => repo
.write_object(&gix::objs::Tree { entries: vec![] })
.map_err(|e| error("empty tree", e))?
.detach(),
};
let ours = local::tree(repo, "HEAD")?;
let (tree, index, conflicts) = merge_trees(
repo,
base,
ours,
decoded.tree(),
"HEAD",
&format!("{} ({subject})", &id.to_string()[..7]),
)?;
checkout::write_tree_to_worktree(repo, Some(ours), tree, false)?;
staging::publish(repo, index)?;
if !conflicts.is_empty() {
std::fs::write(repo.git_dir().join("CHERRY_PICK_HEAD"), format!("{id}\n"))
.map_err(|e| error("cherry-pick state", e))?;
std::fs::write(repo.git_dir().join("ORIG_HEAD"), format!("{old}\n"))
.map_err(|e| error("cherry-pick state", e))?;
std::fs::write(repo.git_dir().join("MERGE_MSG"), decoded.message.as_bytes())
.map_err(|e| error("cherry-pick state", e))?;
output::conflicts(&conflicts, false);
return Ok(1);
}
tree
};
let commit = gix::objs::Commit {
tree,
parents: vec![old].into_iter().collect(),
author: decoded
.author()
.map_err(|e| error("pick author", e))?
.to_owned()
.map_err(|e| error("pick author", e))?,
committer: staging::signature(None, None)?,
encoding: decoded.encoding.map(ToOwned::to_owned),
message: decoded.message.to_owned(),
extra_headers: vec![],
};
let new = repo
.write_object(&commit)
.map_err(|e| error("cherry-pick write", e))?
.detach();
staging::update_head(
repo,
Some(old),
new,
&staging::signature(None, None)?,
&format!("cherry-pick: {subject}"),
)?;
clear(repo)?;
if !quiet {
let head = crate::head_info(repo)?;
println!(
"[{} {}] {subject}",
head.branch
.unwrap_or_else(|| "detached HEAD".into())
.trim_start_matches("refs/heads/"),
&new.to_string()[..7]
);
output::author_date(&repo.find_commit(new).map_err(|e| error("pick output", e))?)?;
output::commit_stat(&crate::changes::diff(crate::changes::DiffParams {
repo: repo.workdir().unwrap().to_string_lossy().into_owned(),
from: Some(old.to_string()),
to: Some(new.to_string()),
staged: None,
path: None,
context: None,
})?);
}
Ok(0)
}
+77
View File
@@ -0,0 +1,77 @@
//! Path-limited history follows a tree-same parent at merges, as Git's default history simplification does.
use crate::{Result, changes, repo::error};
use std::collections::BTreeSet;
pub(crate) fn paths(
repo: &gix::Repository,
start: gix::ObjectId,
path: &str,
) -> Result<Vec<gix::ObjectId>> {
let empty = repo
.write_object(&gix::objs::Tree { entries: vec![] })
.map_err(|e| error("log empty tree", e))?
.detach();
let time = |id| -> Result<i64> {
Ok(repo
.find_commit(id)
.map_err(|e| error("log commit", e))?
.committer()
.map_err(|e| error("log committer", e))?
.time()
.map_err(|e| error("log time", e))?
.seconds)
};
let mut todo = vec![(start, time(start)?)];
let mut visited = BTreeSet::new();
let mut visible = Vec::new();
while !todo.is_empty() {
let next = todo
.iter()
.enumerate()
.min_by_key(|(_, (_, time))| std::cmp::Reverse(*time))
.unwrap()
.0;
let (id, _) = todo.remove(next);
if !visited.insert(id) {
continue;
}
let commit = repo.find_commit(id).map_err(|e| error("log commit", e))?;
let parents = commit
.parent_ids()
.map(|id| id.detach())
.collect::<Vec<_>>();
let mut same = None;
for parent in &parents {
if diff(repo, *parent, id, path)?.is_empty() {
same = Some(*parent);
break;
}
}
if let Some(parent) = same {
todo.push((parent, time(parent)?));
} else {
if !parents.is_empty() || !diff(repo, empty, id, path)?.is_empty() {
visible.push(id);
}
for parent in parents {
todo.push((parent, time(parent)?));
}
}
}
Ok(visible)
}
fn diff(
repo: &gix::Repository,
from: gix::ObjectId,
to: gix::ObjectId,
path: &str,
) -> Result<Vec<changes::FileDiff>> {
changes::diff(changes::DiffParams {
repo: repo.workdir().unwrap().to_string_lossy().into_owned(),
from: Some(from.to_string()),
to: Some(to.to_string()),
staged: None,
path: Some(path.into()),
context: None,
})
}
+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);
}
}
}
}
+283
View File
@@ -0,0 +1,283 @@
//! Local reset, path restoration, tags, remotes, and branch renames use ordinary Git metadata.
use crate::{Result, branches, changes, checkout, head_info, remotes, repo::error, staging};
use gix::bstr::ByteSlice;
use gix::index::State;
#[cfg(unix)]
use gix::index::entry::Mode;
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
pub(crate) fn tree(repo: &gix::Repository, revision: &str) -> Result<gix::ObjectId> {
Ok(repo
.rev_parse_single(revision)
.map_err(|e| error("revision", e))?
.object()
.map_err(|e| error("revision", e))?
.peel_to_tree()
.map_err(|e| error("tree", e))?
.id)
}
fn selected(path: &str, paths: &[String]) -> bool {
paths
.iter()
.any(|p| p == "." || path == p || path.strip_prefix(p).is_some_and(|s| s.starts_with('/')))
}
pub(crate) fn restore(
repo: &gix::Repository,
revision: Option<&str>,
paths: &[String],
staged: bool,
) -> Result<()> {
let source = match revision {
Some(revision) => {
State::from_tree(&tree(repo, revision)?, &repo.objects, Default::default())
.map_err(|e| error("restore tree", e))?
}
None => staging::load(repo)?,
};
let mut state = staging::load(repo)?;
if staged {
state.remove_entries(|_, p, _| selected(p.to_str_lossy().as_ref(), paths));
for e in source
.entries()
.iter()
.filter(|e| selected(e.path(&source).to_str_lossy().as_ref(), paths))
{
state.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, e.path(&source));
}
staging::publish(repo, state)?;
} else {
let root = crate::repo::worktree(repo)?;
for path in paths {
if path != "." {
crate::repo::validate_path(path)?;
}
}
for e in source
.entries()
.iter()
.filter(|e| selected(e.path(&source).to_str_lossy().as_ref(), paths))
{
if e.stage_raw() != 0 {
return Err(error("restore", "unmerged path"));
}
staging::safe(root, e.path(&source).to_str_lossy().as_ref())?;
let path = root.join(e.path(&source).to_str_lossy().as_ref());
let bytes = repo
.find_blob(e.id)
.map_err(|e| error("restore blob", e))?
.data
.to_vec();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| error("restore directory", e))?;
}
std::fs::write(&path, bytes).map_err(|e| error("restore file", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
&path,
std::fs::Permissions::from_mode(if e.mode == Mode::FILE_EXECUTABLE {
0o755
} else {
0o644
}),
)
.map_err(|e| error("restore mode", e))?;
}
}
}
Ok(())
}
pub(crate) fn reset(
repo: &gix::Repository,
revision: &str,
paths: &[String],
soft: bool,
hard: bool,
quiet: bool,
) -> Result<()> {
if !paths.is_empty() {
restore(repo, Some(revision), paths, true)?;
} else {
let id = branches::resolve(repo, revision)?;
let old = head_info(repo)?.id;
if hard {
checkout::write_tree_to_worktree(repo, None, tree(repo, revision)?, true)?;
} else if !soft {
let state = State::from_tree(&tree(repo, revision)?, &repo.objects, Default::default())
.map_err(|e| error("reset index", e))?;
staging::publish(repo, state)?;
}
if let Some(old) = old {
std::fs::write(repo.git_dir().join("ORIG_HEAD"), format!("{old}\n"))
.map_err(|e| error("reset ORIG_HEAD", e))?;
staging::update_head(
repo,
Some(gix::ObjectId::from_hex(old.as_bytes()).map_err(|e| error("reset HEAD", e))?),
id,
&staging::signature(None, None)?,
"reset",
)?;
}
if hard && !quiet {
let commit = repo.find_commit(id).map_err(|e| error("reset commit", e))?;
println!(
"HEAD is now at {} {}",
&id.to_string()[..7],
commit
.decode()
.map_err(|e| error("reset message", e))?
.message
.to_str_lossy()
.lines()
.next()
.unwrap_or("")
);
}
}
if !soft && !hard && !quiet {
let status = changes::status(&crate::repo::open(
&repo.workdir().unwrap().to_string_lossy(),
)?)?;
if !status.unstaged.is_empty() {
println!("Unstaged changes after reset:");
for c in status.unstaged {
println!("{}\t{}", crate::code(&c.status), c.path);
}
}
}
Ok(())
}
pub(crate) fn rename_branch(repo: &gix::Repository, old: Option<&str>, new: &str) -> Result<()> {
let head = head_info(repo)?;
let old = branches::branch_name(
old.unwrap_or(
head.branch
.as_deref()
.unwrap_or("HEAD")
.trim_start_matches("refs/heads/"),
),
)?;
let new = branches::branch_name(new)?;
crate::worktrees::ensure_available(repo, &old)?;
let mut edits = vec![
RefEdit {
name: new
.as_str()
.try_into()
.map_err(|e| error("branch name", e))?,
deref: false,
change: Change::Update {
expected: PreviousValue::MustNotExist,
new: Target::Object(branches::resolve(repo, &old)?),
log: Default::default(),
},
},
RefEdit {
name: old
.as_str()
.try_into()
.map_err(|e| error("branch name", e))?,
deref: false,
change: Change::Delete {
expected: PreviousValue::MustExist,
log: gix::refs::transaction::RefLog::AndReference,
},
},
];
if head.branch.as_deref() == Some(old.as_str()) {
edits.push(RefEdit {
name: "HEAD".try_into().unwrap(),
deref: false,
change: Change::Update {
expected: PreviousValue::Any,
new: Target::Symbolic(new.as_str().try_into().unwrap()),
log: Default::default(),
},
});
}
branches::edit_refs(repo, edits)?;
let mut config = remotes::load(repo)?;
let old_short = old.trim_start_matches("refs/heads/");
if config.section("branch", old_short).is_ok() {
config
.rename_section(
"branch",
old_short,
"branch",
new.trim_start_matches("refs/heads/"),
)
.map_err(|e| error("branch config", e))?;
remotes::save(repo, &config)?;
}
Ok(())
}
pub(crate) fn tag(
repo: &gix::Repository,
name: Option<String>,
revision: Option<String>,
annotated: bool,
message: Option<String>,
delete: bool,
) -> Result<()> {
let Some(name) = name else {
for reference in repo
.references()
.map_err(|e| error("tags", e))?
.all()
.map_err(|e| error("tags", e))?
{
let reference = reference.map_err(|e| error("tag", e))?;
if reference.name().as_bstr().starts_with(b"refs/tags/") {
println!("{}", reference.name().shorten());
}
}
return Ok(());
};
let full = format!("refs/tags/{name}");
if delete {
let reference = repo
.find_reference(full.as_str())
.map_err(|e| error("tag", e))?;
let id = reference
.target()
.try_id()
.ok_or_else(|| error("tag", "symbolic tag"))?
.to_string();
reference.delete().map_err(|e| error("tag delete", e))?;
println!("Deleted tag '{name}' (was {})", &id[..7]);
} else {
let id = branches::resolve(repo, revision.as_deref().unwrap_or("HEAD"))?;
let id = if annotated || message.is_some() {
let object = gix::objs::Tag {
target: id,
target_kind: gix::objs::Kind::Commit,
name: name.clone().into(),
tagger: Some(staging::signature(None, None)?),
message: format!("{}\n", message.unwrap_or_default().trim_end()).into(),
signature: None,
};
repo.write_object(&object)
.map_err(|e| error("tag object", e))?
.detach()
} else {
id
};
branches::edit_refs(
repo,
vec![RefEdit {
name: full.as_str().try_into().map_err(|e| error("tag name", e))?,
deref: false,
change: Change::Update {
expected: PreviousValue::MustNotExist,
new: Target::Object(id),
log: Default::default(),
},
}],
)?;
}
Ok(())
}
+21 -1
View File
@@ -36,6 +36,19 @@ pub(crate) fn commit(commit: &gix::Commit<'_>) -> crate::Result<()> {
);
Ok(())
}
pub(crate) fn author_date(commit: &gix::Commit<'_>) -> crate::Result<()> {
let author = commit
.author()
.map_err(|e| crate::repo::error("author", e))?;
println!(
" Date: {}",
author
.time()
.map_err(|e| crate::repo::error("date", e))?
.format_or_unix(gix::date::time::format::DEFAULT)
);
Ok(())
}
pub(crate) fn long_status(s: &Status) {
let branch = s
.head
@@ -43,7 +56,14 @@ pub(crate) fn long_status(s: &Status) {
.as_deref()
.unwrap_or("HEAD")
.trim_start_matches("refs/heads/");
println!("On branch {branch}");
if s.head.detached {
println!(
"HEAD detached at {}",
&s.head.id.as_deref().unwrap_or("HEAD")[..7]
);
} else {
println!("On branch {branch}");
}
if s.head.unborn {
println!("\nNo commits yet\n");
}
+7 -5
View File
@@ -116,7 +116,7 @@ pub fn set(repo: &gix::Repository, name: &str, url: &str, add: bool) -> Result<R
},
));
}
// Replace all URL occurrences (including duplicate sections), preserving other keys/comments.
// Change URL values in place, preserving config order and other keys/comments.
if !add {
let ids: Vec<_> = config
.sections_and_ids_by_name("remote")
@@ -131,17 +131,19 @@ pub fn set(repo: &gix::Repository, name: &str, url: &str, add: bool) -> Result<R
.collect();
for id in ids {
if let Some(mut section) = config.section_mut_by_id(id) {
while section.remove("url").is_some() {}
section
.set("url", url.as_str())
.map_err(|e| error("remote config", e))?;
}
}
}
let mut section = config
.section_mut_or_create_new("remote", name)
.map_err(|e| error("remote config", e))?;
section
.push("url", Some(url.as_str().into()))
.map_err(|e| error("remote config", e))?;
if add {
section
.push("url", Some(url.as_str().into()))
.map_err(|e| error("remote config", e))?;
section
.push(
"fetch",
+12 -2
View File
@@ -116,7 +116,7 @@ fn meta(path: &Path) -> Result<Option<fs::Metadata>> {
}
}
// Do not traverse symlink parents even when an explicit path points outside via a link.
fn safe(root: &Path, path: &str) -> Result<()> {
pub(crate) fn safe(root: &Path, path: &str) -> Result<()> {
validate(path)?;
for parent in Path::new(path)
.ancestors()
@@ -129,7 +129,7 @@ fn safe(root: &Path, path: &str) -> Result<()> {
}
Ok(())
}
fn load(repo: &gix::Repository) -> Result<State> {
pub(crate) fn load(repo: &gix::Repository) -> Result<State> {
repo::worktree(repo)?;
let index = repo.index_or_empty().map_err(|e| error("stage index", e))?;
let mut state = State::new(repo.object_hash());
@@ -324,6 +324,16 @@ fn stage(repo: &gix::Repository, state: State, selected: BTreeSet<String>) -> Re
state.sort_entries();
Ok(state)
}
pub(crate) fn tracked_tree(repo: &gix::Repository) -> Result<gix::ObjectId> {
let state = load(repo)?;
let selected = state
.entries()
.iter()
.filter(|e| e.mode != Mode::COMMIT)
.map(|e| e.path(&state).to_str_lossy().into_owned())
.collect();
tree_from_index(repo, &stage(repo, state, selected)?)
}
pub(crate) fn add(repo: &gix::Repository, p: AddParams) -> Result<StagedPaths> {
if !p.all.unwrap_or(false) {
validate_paths(&p.paths)?;
+227
View File
@@ -0,0 +1,227 @@
//! Stashes are standard two-parent Git commits with a refs/stash reflog.
use crate::{
Result, branches, changes, checkout, cherry_pick, local, output, repo::error, staging,
};
use gix::{
bstr::ByteSlice,
refs::{
Target,
transaction::{Change, LogChange, PreviousValue, RefEdit},
},
};
fn log_path(repo: &gix::Repository) -> std::path::PathBuf {
repo.common_dir().join("logs/refs/stash")
}
fn logs(repo: &gix::Repository) -> Result<Vec<String>> {
match std::fs::read_to_string(log_path(repo)) {
Ok(text) => Ok(text.lines().map(str::to_owned).collect()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
Err(e) => Err(error("stash log", e)),
}
}
fn commit(
repo: &gix::Repository,
tree: gix::ObjectId,
parents: Vec<gix::ObjectId>,
message: &str,
) -> Result<gix::ObjectId> {
let sig = staging::signature(None, None)?;
repo.write_object(&gix::objs::Commit {
tree,
parents: parents.into_iter().collect(),
author: sig.clone(),
committer: sig,
encoding: None,
message: message.into(),
extra_headers: vec![],
})
.map(|id| id.detach())
.map_err(|e| error("stash commit", e))
}
fn update(repo: &gix::Repository, id: gix::ObjectId, message: &str) -> Result<()> {
branches::edit_refs(
repo,
vec![RefEdit {
name: "refs/stash".try_into().unwrap(),
deref: false,
change: Change::Update {
expected: PreviousValue::Any,
new: Target::Object(id),
log: LogChange {
message: message.into(),
force_create_reflog: true,
..Default::default()
},
},
}],
)?;
Ok(())
}
fn drop_entry(repo: &gix::Repository, n: usize, id: gix::ObjectId, quiet: bool) -> Result<()> {
let mut rows = logs(repo)?;
let offset = rows
.len()
.checked_sub(n + 1)
.ok_or_else(|| error("stash", "no such stash"))?;
rows.remove(offset);
if rows.is_empty() {
repo.find_reference("refs/stash")
.map_err(|e| error("stash ref", e))?
.delete()
.map_err(|e| error("stash drop", e))?;
} else {
let tip = rows.last().unwrap().split_whitespace().nth(1).unwrap();
update(
repo,
gix::ObjectId::from_hex(tip.as_bytes()).map_err(|e| error("stash id", e))?,
"stash drop",
)?;
std::fs::write(log_path(repo), format!("{}\n", rows.join("\n")))
.map_err(|e| error("stash log", e))?;
}
if !quiet {
println!("Dropped refs/stash@{{{n}}} ({id})");
}
Ok(())
}
pub(crate) fn run(
repo: &gix::Repository,
command: &str,
message: Option<String>,
entry: Option<String>,
quiet: bool,
) -> Result<i32> {
if !matches!(command, "push" | "list" | "apply" | "pop" | "drop") {
return Err(error("stash", "unsupported subcommand"));
}
if command == "push" {
crate::merge::ensure_idle(repo)?;
let status = changes::status(repo)?;
if status.staged.is_empty() && status.unstaged.is_empty() {
if !quiet {
println!("No local changes to save");
}
return Ok(0);
}
let head = branches::resolve(repo, "HEAD")?;
let original = repo.find_commit(head).map_err(|e| error("stash HEAD", e))?;
let subject = original
.decode()
.map_err(|e| error("stash message", e))?
.message
.to_str_lossy()
.lines()
.next()
.unwrap_or("")
.to_owned();
let branch = crate::head_info(repo)?
.branch
.unwrap_or_else(|| "(no branch)".into())
.trim_start_matches("refs/heads/")
.to_owned();
let description = format!("{branch}: {} {subject}", &head.to_string()[..7]);
let index = repo.index_or_empty().map_err(|e| error("stash index", e))?;
let index_tree = staging::tree_from_index(repo, &index)?;
let index_commit = commit(
repo,
index_tree,
vec![head],
&format!("index on {description}\n"),
)?;
let work_tree = staging::tracked_tree(repo)?;
let message = message
.map(|m| format!("On {branch}: {m}"))
.unwrap_or_else(|| format!("WIP on {description}"));
let stash = commit(repo, work_tree, vec![head, index_commit], &message)?;
update(repo, stash, &message)?;
checkout::write_tree_to_worktree(repo, None, local::tree(repo, "HEAD")?, true)?;
if !quiet {
println!("Saved working directory and index state {message}");
}
return Ok(0);
}
let rows = logs(repo)?;
if command == "list" {
for (n, row) in rows.iter().rev().enumerate() {
println!(
"stash@{{{n}}}: {}",
row.split_once('\t').map(|(_, m)| m).unwrap_or("")
);
}
return Ok(0);
}
let n = entry
.as_deref()
.map(|s| {
s.strip_prefix("stash@{")
.and_then(|s| s.strip_suffix('}'))
.unwrap_or(s)
.parse::<usize>()
.map_err(|e| error("stash entry", e))
})
.transpose()?
.unwrap_or(0);
let Some(row) = rows.iter().rev().nth(n) else {
eprintln!("No stash entries found.");
return Ok(1);
};
let id = gix::ObjectId::from_hex(row.split_whitespace().nth(1).unwrap().as_bytes())
.map_err(|e| error("stash id", e))?;
if command == "drop" {
drop_entry(repo, n, id, quiet)?;
return Ok(0);
}
if !matches!(command, "apply" | "pop") {
eprintln!("usage: git stash [push|list|pop|apply|drop]");
return Ok(129);
}
let stash = repo.find_commit(id).map_err(|e| error("stash commit", e))?;
let decoded = stash.decode().map_err(|e| error("stash decode", e))?;
let base = local::tree(repo, &decoded.parents[0].to_string())?;
let old_index = repo.index_or_empty().map_err(|e| error("stash index", e))?;
let ours = staging::tracked_tree(repo)?;
let (merged, mut index, conflicts) = cherry_pick::merge_trees(
repo,
base,
ours,
decoded.tree(),
"Updated upstream",
"Stashed changes",
)?;
checkout::write_tree_to_worktree(repo, Some(ours), merged, true)?;
if conflicts.is_empty() {
// Without --index, tracked modifications remain unstaged. Stashed additions stay staged.
let base_index = gix::index::State::from_tree(&base, &repo.objects, Default::default())
.map_err(|e| error("stash base", e))?;
let added = index
.entries()
.iter()
.filter(|e| {
!base_index
.entries()
.iter()
.any(|b| b.path(&base_index) == e.path(&index))
})
.map(|e| e.path(&index).to_str_lossy().into_owned())
.collect::<std::collections::BTreeSet<_>>();
index.remove_entries(|_, path, _| !added.contains(path.to_str_lossy().as_ref()));
for e in old_index.entries() {
if !added.contains(e.path(&old_index).to_str_lossy().as_ref()) {
index.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, e.path(&old_index));
}
}
}
staging::publish(repo, index)?;
let repo = crate::repo::open(&repo.workdir().unwrap().to_string_lossy())?;
output::conflicts(&conflicts, false);
if !quiet {
output::long_status(&changes::status(&repo)?);
}
if !conflicts.is_empty() {
return Ok(1);
}
if command == "pop" {
drop_entry(&repo, n, id, quiet)?;
}
Ok(0)
}
+189 -2
View File
@@ -16,15 +16,29 @@ def main [component: path] {
with-env $envs {
for side in [native wasi] { mkdir ($root | path join $side repo) }
let steps = [
{args: [init -b main], compare: false}
{args: [init -q -b main], compare: true}
{args: [config user.name Agent], compare: true}
{args: [config user.email agent@example.test], compare: true}
{args: [-c user.name=Override config --get user.name], compare: true}
{args: [config --get user.name], compare: true}
{write: 'base\n', file: 'file.txt'}
{args: [add .], compare: true}
{args: [commit -m initial], compare: true}
{args: [status --short], compare: true}
{args: [status -sb], compare: true}
{args: [status --porcelain=v1], compare: true}
{args: [status], compare: true}
{args: [ls-files], compare: true}
{args: [branch --show-current], compare: true}
{args: [branch -a], compare: true}
{args: [log -n 1], compare: true}
{args: [log -n 1 '--format=%H %h %s %an %ae %ad %d'], compare: true}
{args: [log -n 1 '--pretty=format:%H %h %s %an %ae %ad %d'], compare: true}
{args: [log -n 1 -p], compare: true}
{args: [log -n 1 --stat], compare: true}
{args: [log --oneline -- file.txt], compare: true}
{args: [show --stat], compare: true}
{args: [rev-parse --short HEAD], compare: true}
{args: [show HEAD], compare: true}
{args: [show HEAD:file.txt], compare: true}
{args: [branch topic], compare: true}
@@ -32,6 +46,18 @@ def main [component: path] {
{write: 'topic\n', file: 'topic.txt'}
{args: [add .], compare: true}
{args: [commit -m topic], compare: true}
{args: [rev-parse HEAD HEAD~1], compare: true}
{args: [diff HEAD~1], compare: true}
{args: [diff HEAD~1..HEAD], compare: true}
{args: [diff --name-only HEAD~1..HEAD], compare: true}
{args: [diff --name-status HEAD~1..HEAD], compare: true}
{args: [diff HEAD~1 -- topic.txt], compare: true}
{args: [branch disposable], compare: true}
{args: [branch -d disposable], compare: true}
{args: [branch disposable], compare: true}
{args: [branch -D disposable], compare: true}
{args: [branch disposable], compare: true}
{args: [branch -q -d disposable], compare: true}
{args: [switch main], compare: true}
{args: [merge topic], compare: true}
{write: 'changed\n', file: 'file.txt'}
@@ -48,6 +74,8 @@ def main [component: path] {
{args: [merge topic], compare: true, exit: 1}
{args: [status --short], compare: true}
{args: [merge --abort], compare: true}
{args: [merge -q topic], compare: true, exit: 1}
{args: [merge --abort], compare: true}
{args: [worktree add -b linked ../linked], compare: true}
{args: [worktree list], compare: false}
{args: [worktree remove ../linked], compare: true}
@@ -56,6 +84,7 @@ def main [component: path] {
{write: 'resolved\n', file: 'file.txt'}
{args: [add file.txt], compare: true}
{args: [rebase --continue], compare: true}
{args: [rebase -q main], compare: true}
{args: [log --oneline], compare: true}
{args: [switch main], compare: true}
{write: 'left\n', file: 'left.txt'}
@@ -67,11 +96,137 @@ def main [component: path] {
{args: [commit -m right], compare: true}
{args: [switch main], compare: true}
{args: [merge topic], compare: true}
{args: [show HEAD], compare: true}
{args: [branch], compare: true}
{args: [rm right.txt], compare: true}
{args: [diff --cached], compare: true}
{args: [status --porcelain], compare: true}
{args: [commit -m removed], compare: true}
{args: [checkout HEAD~1], compare: true}
{args: [status], compare: true}
{args: [status -sb], compare: true}
{args: [switch main], compare: true}
{args: [checkout -q -b agent-checkout], compare: true}
{args: [switch -q main], compare: true}
{args: [switch -q -c agent-switch], compare: true}
{write: 'agent\n', file: 'agent.txt'}
{args: [add agent.txt], compare: true}
{args: [commit -q -m agent], compare: true}
{args: [switch main], compare: true}
{args: [merge --no-ff -q agent-switch], compare: true}
{args: [show --stat], compare: true}
{args: [commit --quiet --allow-empty -m quiet], compare: true}
{args: [init], compare: true, normalize_path: true, state: true}
{args: [init -q], compare: true, state: true}
{args: [log -n 1 --oneline -- file.txt], compare: true}
{args: [log -n 2 --oneline -- file.txt], compare: true}
{args: [config branch.main.description agent-branch], compare: true}
{args: [branch -m rework], compare: true, state: true}
{args: [config --get branch.rework.description], compare: true}
{args: [branch -m agent-checkout renamed], compare: true, state: true}
{args: [branch], compare: true}
{args: [remote add example https://example.test/repo.git], compare: true, state: true}
{args: [remote -v], compare: true}
{args: [remote set-url example https://example.test/new.git], compare: true, state: true}
{args: [remote get-url example], compare: true}
{args: [remote remove example], compare: true, state: true}
{args: [remote -v], compare: true}
{args: [tag lightweight], compare: true, state: true}
{args: [tag -a annotated -m annotation], compare: true, state: true}
{args: [tag], compare: true}
{args: [tag --list], compare: true}
{args: [tag -d annotated], compare: true, state: true}
{args: [tag -d lightweight], compare: true, state: true}
{write: 'amended\n', file: 'agent.txt'}
{args: [add agent.txt], compare: true}
{args: [commit --amend -m amended], compare: true, state: true}
{args: [commit --amend --no-edit], compare: true, state: true}
{write: 'file message\n\nbody\n', file: 'message.txt'}
{write: 'from-file\n', file: 'agent.txt'}
{args: [add agent.txt], compare: true}
{args: [commit -F message.txt], compare: true, state: true}
{write: 'dirty\n', file: 'agent.txt'}
{args: [checkout agent.txt], compare: true, state: true}
{write: 'dirty\n', file: 'agent.txt'}
{args: [checkout -- agent.txt], compare: true, state: true}
{write: 'dirty\n', file: 'agent.txt'}
{args: [restore agent.txt], compare: true, state: true}
{write: 'staged\n', file: 'agent.txt'}
{args: [add agent.txt], compare: true}
{args: [restore --staged agent.txt], compare: true, state: true}
{args: [status --short], compare: true}
{args: [restore agent.txt], compare: true, state: true}
{write: 'staged\n', file: 'agent.txt'}
{args: [add agent.txt], compare: true}
{args: [reset HEAD agent.txt], compare: true, state: true}
{args: [restore agent.txt], compare: true, state: true}
{args: [branch saved], compare: true}
{args: [reset --soft HEAD~1], compare: true, state: true}
{args: [status --short], compare: true}
{args: [log --oneline -n 2], compare: true}
{args: [reset --hard saved], compare: true, state: true}
{args: [reset HEAD~1], compare: true, state: true}
{args: [status --short], compare: true}
{args: [reset --hard saved], compare: true, state: true}
{args: [reset --hard HEAD~1], compare: true, state: true}
{args: [reset --hard saved], compare: true, state: true}
{write: 'stash one\n', file: 'agent.txt'}
{args: [stash push], compare: true, state: true}
{args: [stash list], compare: true}
{args: [stash bogus], compare: true, exit: 128, state: true}
{args: [stash apply], compare: true, state: true}
{args: [reset --hard], compare: true, state: true}
{args: [stash drop], compare: true, state: true}
{write: 'stash two\n', file: 'agent.txt'}
{args: [stash -m custom], compare: true, state: true}
{args: [stash list], compare: true}
{write: 'stash three\n', file: 'agent.txt'}
{args: [stash], compare: true, state: true}
{args: [stash list], compare: true}
{args: [stash drop], compare: true, state: true}
{args: [stash pop], compare: true, state: true}
{args: [status --short], compare: true}
{args: [reset --hard], compare: true, state: true}
{args: [stash push], compare: true, state: true}
{write: 'added stash\n', file: 'stash-added.txt'}
{args: [add stash-added.txt], compare: true}
{write: 'stage stash\n', file: 'agent.txt'}
{args: [add agent.txt], compare: true}
{write: 'unstaged stash\n', file: 'agent.txt'}
{args: [stash push -m staged], compare: true, state: true}
{args: [stash apply], compare: true, state: true}
{args: [status --short], compare: true}
{args: [reset --hard], compare: true, state: true}
{args: [stash drop], compare: true, state: true}
{write: 'quiet stash\n', file: 'agent.txt'}
{args: [stash push -q -m quiet], compare: true, state: true}
{args: [stash pop -q], compare: true, state: true}
{args: [stash push -q], compare: true, state: true}
{args: [stash drop -q], compare: true, state: true}
{args: [stash push -q], compare: true, state: true}
{args: [switch -c picks], compare: true}
{write: 'picked\n', file: 'pick.txt'}
{args: [add pick.txt], compare: true}
{args: [commit -m picked], compare: true}
{args: [switch rework], compare: true}
{args: [cherry-pick picks], compare: true, state: true}
{args: [log --oneline -n 2], compare: true}
{args: [switch picks], compare: true}
{write: 'theirs\n', file: 'agent.txt'}
{args: [commit -am theirs], compare: true}
{args: [switch rework], compare: true}
{write: 'ours\n', file: 'agent.txt'}
{args: [commit -am ours], compare: true}
{args: [cherry-pick picks], compare: true, exit: 1, state: true}
{args: [status --short], compare: true}
{args: [cherry-pick --abort], compare: true, state: true}
{args: [cherry-pick picks], compare: true, exit: 1, state: true}
{write: 'resolved\n', file: 'agent.txt'}
{args: [add agent.txt], compare: true}
{args: [cherry-pick --continue], compare: true, state: true}
{args: [log --oneline -n 3], compare: true}
{args: [status --short], compare: true}
{args: [cherry-pick --abort], compare: true, exit: 128, state: true}
{args: [status --bad-option], compare: true, exit: 129}
{args: [not-a-command], compare: true, exit: 1}
]
@@ -85,13 +240,45 @@ def main [component: path] {
let native = (^git -C ($root | path join native repo) ...$args | complete)
let wasi = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($root)/wasi::/' --env GIT_AUTHOR_NAME --env GIT_AUTHOR_EMAIL --env GIT_COMMITTER_NAME --env GIT_COMMITTER_EMAIL --env GIT_AUTHOR_DATE --env GIT_COMMITTER_DATE $component -C /repo ...$args | complete)
let expected = ($step.exit? | default 0)
let equal = ($native.exit_code == $expected and $wasi.exit_code == $expected and (not $step.compare or $native.stdout == $wasi.stdout))
let native_stdout = if ($step.normalize_path? | default false) { $native.stdout | str replace --all ($root | path join native repo) '/repo' } else { $native.stdout }
let equal = ($native.exit_code == $expected and $wasi.exit_code == $expected and (not $step.compare or $native_stdout == $wasi.stdout))
print $'($args | str join " "): (if $equal {"PASS"} else {"FAIL"}) native=($native.exit_code) wasi=($wasi.exit_code)'
if not $equal {
$failures += 1
print ({native: $native, wasi: $wasi} | to json)
}
if ($step.state? | default false) {
for check in [[status --short] [log '--format=%H %P %an %ae %s' --all] [diff] [diff --cached] [show-ref] [config --get-regexp '^(branch|remote)\.']] {
let native_state = (^git -C ($root | path join native repo) ...$check | complete)
let wasi_state = (^git -C ($root | path join wasi repo) ...$check | complete)
let same = ($native_state.exit_code == $wasi_state.exit_code and $native_state.stdout == $wasi_state.stdout)
print $' state ($check | str join " "): (if $same {"PASS"} else {"FAIL"})'
if not $same { $failures += 1; print ({native: $native_state, wasi: $wasi_state} | to json) }
}
}
}
# Explicit usage checks for documented forms that this CLI does not implement.
let unsupported = [
[log --graph --oneline --all]
]
for args in $unsupported {
let result = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($root)/wasi::/' $component -C /repo ...$args | complete)
let expected = 129
let equal = ($result.exit_code == $expected and $result.stdout == '' and ($result.stderr | str lowercase | str contains 'usage'))
print $'($args | str join " "): (if $equal {"PASS documented usage"} else {"FAIL documented usage"}) wasi=($result.exit_code)'
if not $equal { $failures += 1; print ($result | to json) }
}
# A merge tree that differs from both parents requires a combined diff.
'combined resolution\n' | save --force ($root | path join native/repo/file.txt)
let native = ($root | path join native/repo)
^git -C $native add file.txt
let tree = (^git -C $native write-tree | str trim)
let merge = (^git -C $native commit-tree $tree -p HEAD -p HEAD~1 -m combined | str trim)
let result = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($root)/native/repo::/combined' $component -C /combined show $merge | complete)
let native_result = (^git -C $native show $merge | complete)
let equal = ($native_result.exit_code == 0 and $result.exit_code == 129 and $result.stdout == '' and ($result.stderr | str contains 'usage'))
print $'show combined merge: (if $equal {"PASS documented usage"} else {"FAIL documented usage"}) native=($native_result.exit_code) wasi=($result.exit_code)'
if not $equal { $failures += 1; print ($result | to json) }
if $failures > 0 {error make {msg: $'($failures) workflow comparisons failed'}}
}
}