Provide world Git identity, reflog fallback, object inspection, and which (T73)

This commit is contained in:
2026-11-08 02:28:34 +00:00
parent 1892642d8e
commit fdda49394f
11 changed files with 450 additions and 30 deletions
+16 -2
View File
@@ -49,6 +49,18 @@ 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.
## Identity
The host creates `/etc/gitconfig` when it is absent.
It sets `user.name = Sausage` and `user.email = sausage@localhost`.
Git reads this system configuration unless `GIT_CONFIG_NOSYSTEM` disables it.
Local repository configuration overrides these defaults. The host preserves an existing system configuration.
The `Sausage-Conversation` trailer retains conversation attribution.
Reflog-only operations use a fallback committer when no identity exists.
They include reset, checkout, branch changes, and reference updates.
New commits and annotated tags still require an identity.
## Agent command forms
The comparison suite covers per-command `-c key=value` overrides and quiet local commands.
@@ -60,10 +72,12 @@ It also checks these forms:
- `log --format` and `--pretty=format:` with `%H %h %s %an %ae %ad %d`.
- `log A..B`, `--name-only`, `--no-renames`, and `--diff-merges=first-parent`.
- `%x00`, `%x1e`, and `%(trailers:key=Sausage-Conversation,valueonly)` in log formats.
- `show --stat`, `show HEAD:path`, and merge commits without a combined diff.
- `show --stat`, `--oneline`, `--name-only`, `show HEAD:path`, and merge commits without a combined diff.
- `cat-file -t`, `-p`, `-s`, and `-e` for object inspection.
- `ls-tree [-r] [--name-only] <rev> [path]` for literal paths.
- `diff HEAD~1`, `diff A..B`, `--name-only`, `--name-status`, and one literal path after `--`.
- `diff --check` and `diff --cached --check` for added whitespace errors and conflict markers.
- `branch -a`, `-d`, `-D`, `-m`, and `--show-current`.
- `branch -a`, `-d`, `-D`, `-f`, `-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`.
+44 -20
View File
@@ -57,30 +57,54 @@ pub(crate) struct BranchDeleted {
pub reference: String,
}
/// Reflogs require an identity, but switching branches must not require configuring one.
/// With no repository identity, leave reflogs untouched and report that explicitly; never invent an author.
/// Reflog-only writes use a fallback identity when the repository has none.
pub(crate) fn reflog_signature(repo: &gix::Repository) -> Result<gix::actor::Signature> {
if let Some(signature) = repo
.committer()
.transpose()
.map_err(|e| error("reflog identity", e))?
{
return signature
.to_owned()
.map_err(|e| error("reflog identity", e));
}
let config = repo.config_snapshot();
let name = std::env::var("GIT_COMMITTER_NAME")
.ok()
.or_else(|| config.string("user.name").map(|s| s.to_string()))
.or_else(|| std::env::var("USER").ok())
.unwrap_or_else(|| "Sausage".into());
let email = std::env::var("GIT_COMMITTER_EMAIL")
.ok()
.or_else(|| config.string("user.email").map(|s| s.to_string()))
.unwrap_or_else(|| {
format!(
"{}@{}",
std::env::var("USER").unwrap_or_else(|_| "sausage".into()),
std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".into())
)
});
let seconds = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
Ok(gix::actor::Signature {
name: name.into(),
email: email.into(),
time: gix::date::Time::new(seconds, 0),
})
}
/// Publish references and their reflog entries without requiring a commit identity.
pub(crate) fn edit_refs(
repo: &gix::Repository,
edits: Vec<gix::refs::transaction::RefEdit>,
) -> Result<Vec<String>> {
let committer = repo
.committer()
.transpose()
.map_err(|e| error("reflog identity", e))?;
if let Some(committer) = committer {
repo.edit_references_as(edits, Some(committer))
.map_err(|e| error("checkout refs", e))?;
Ok(Vec::new())
} else {
let mut local = repo.clone();
local.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
local
.edit_references_as(edits, None)
.map_err(|e| error("checkout refs", e))?;
Ok(vec![
"reflog not written: no repository committer identity configured".into(),
])
}
let signature = reflog_signature(repo)?;
let mut time = Default::default();
repo.edit_references_as(edits, Some(signature.to_ref(&mut time)))
.map_err(|e| error("checkout refs", e))?;
Ok(Vec::new())
}
pub(crate) fn branch_name(name: &str) -> Result<String> {
+92
View File
@@ -0,0 +1,92 @@
//! Read Git object types, sizes, contents, and tree entries without changing repository state.
use crate::{Result, repo::error};
use std::io::Write;
pub(crate) fn cat_file(
repo: &gix::Repository,
revision: &str,
kind: bool,
size: bool,
exists: bool,
) -> Result<()> {
let object = repo
.rev_parse_single(revision)
.map_err(|e| error("cat-file", e))?
.object()
.map_err(|e| error("cat-file", e))?;
if exists {
return Ok(());
}
if kind {
println!("{}", object.kind);
} else if size {
println!("{}", object.data.len());
} else if object.kind == gix::objs::Kind::Tree {
ls_tree(repo, revision, false, false, None)?;
} else {
std::io::stdout()
.write_all(&object.data)
.map_err(|e| error("cat-file", e))?;
}
Ok(())
}
pub(crate) fn ls_tree(
repo: &gix::Repository,
revision: &str,
recursive: bool,
names: bool,
path: Option<&str>,
) -> Result<()> {
let id = crate::local::tree(repo, revision)?;
fn walk(
repo: &gix::Repository,
id: gix::ObjectId,
prefix: &str,
recursive: bool,
names: bool,
filter: Option<&str>,
) -> Result<()> {
let tree = repo.find_tree(id).map_err(|e| error("ls-tree", e))?;
for entry in tree.iter() {
let entry = entry.map_err(|e| error("ls-tree", e))?;
let filename = format!("{prefix}{}", entry.filename());
let directory = entry.mode().is_tree();
let descend = directory
&& (recursive
|| filter.is_some_and(|path| path.starts_with(&format!("{filename}/"))));
if descend {
walk(
repo,
entry.oid().to_owned(),
&format!("{filename}/"),
recursive,
names,
filter,
)?;
} else if filter.is_none_or(|path| {
filename == path
|| filename.starts_with(&format!("{}/", path.trim_end_matches('/')))
}) {
if names {
println!("{filename}");
} else {
let kind = if directory {
"tree"
} else if entry.mode().is_commit() {
"commit"
} else {
"blob"
};
println!(
"{:06o} {kind} {}\t{filename}",
entry.mode().kind() as u32,
entry.oid()
);
}
}
}
Ok(())
}
walk(repo, id, "", recursive, names, path)
}
+82 -5
View File
@@ -59,6 +59,7 @@ fn identity(name: Option<String>, email: Option<String>) -> Result<Identity> {
mod cherry_pick;
mod history;
mod local;
mod inspect;
mod plumbing;
mod stash;
use clap::{Parser, Subcommand};
@@ -79,8 +80,32 @@ enum Command {
Show {
#[arg(long)]
stat: bool,
#[arg(long)]
oneline: bool,
#[arg(long)]
name_only: bool,
revision: Option<String>,
},
#[command(group(clap::ArgGroup::new("mode").required(true).multiple(false)))]
CatFile {
#[arg(short = 't', group = "mode")]
kind: bool,
#[arg(short = 'p', group = "mode")]
pretty: bool,
#[arg(short = 's', group = "mode")]
size: bool,
#[arg(short = 'e', group = "mode")]
exists: bool,
revision: String,
},
LsTree {
#[arg(short = 'r')]
recursive: bool,
#[arg(long)]
name_only: bool,
revision: String,
path: Option<String>,
},
Clone {
url: String,
path: Option<String>,
@@ -257,6 +282,8 @@ enum Command {
delete: bool,
#[arg(short = 'D')]
force_delete: bool,
#[arg(short = 'f', long)]
force: bool,
#[arg(short = 'm')]
rename: bool,
name: Option<String>,
@@ -1124,6 +1151,7 @@ pub fn run(cli: Cli) -> Result<i32> {
list,
delete,
force_delete,
force,
show_current,
all,
rename,
@@ -1164,6 +1192,14 @@ pub fn run(cli: Cli) -> Result<i32> {
if !quiet {
println!("Deleted branch {name} (was {}).", &id[..7]);
}
} else if force {
let full = branches::branch_name(&name)?;
worktrees::ensure_available(&repository, &full)?;
if head_info(&repository)?.branch.as_deref() == Some(&full) {
return Err(error("branch", "cannot force update current branch"));
}
let id = branches::resolve(&repository, start.as_deref().unwrap_or("HEAD"))?;
plumbing::update_ref(&repository, &full, &id.to_string(), None)?;
} else {
branches::create(
&repository,
@@ -1488,7 +1524,35 @@ pub fn run(cli: Cli) -> Result<i32> {
)?;
}
},
Command::Show { revision, stat } => {
Command::CatFile {
kind,
pretty: _,
size,
exists,
revision,
} => {
inspect::cat_file(&repository, &revision, kind, size, exists)?;
}
Command::LsTree {
recursive,
name_only,
revision,
path,
} => {
inspect::ls_tree(
&repository,
&revision,
recursive,
name_only,
path.as_deref(),
)?;
}
Command::Show {
revision,
stat,
oneline,
name_only,
} => {
let object = repository
.rev_parse_single(revision.as_deref().unwrap_or("HEAD"))
.map_err(|e| error("show", e))?
@@ -1528,9 +1592,18 @@ pub fn run(cli: Cli) -> Result<i32> {
return Ok(129);
}
}
output::commit(&commit)?;
println!();
if merge_commit && !stat {
if oneline {
let decoded = commit.decode().map_err(|e| error("show", e))?;
println!(
"{} {}",
&commit.id.to_string()[..7],
decoded.message.to_str_lossy().lines().next().unwrap_or("")
);
} else {
output::commit(&commit)?;
println!();
}
if merge_commit && !stat && !name_only {
return Ok(0);
}
let old = match commit.parent_ids().next() {
@@ -1548,7 +1621,11 @@ pub fn run(cli: Cli) -> Result<i32> {
path: None,
context: None,
})?;
if stat {
if name_only {
for diff in diffs {
println!("{}", diff.path);
}
} else if stat {
output::stat(&diffs, false);
} else {
for diff in diffs {
+1 -1
View File
@@ -118,7 +118,7 @@ pub(crate) fn reset(
repo,
Some(gix::ObjectId::from_hex(old.as_bytes()).map_err(|e| error("reset HEAD", e))?),
id,
&staging::signature(None, None)?,
&branches::reflog_signature(repo)?,
"reset",
)?;
}
+53
View File
@@ -64,6 +64,22 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t
{args: [log -n 1 --stat], compare: true}
{args: [log --oneline -- file.txt], compare: true}
{args: [show --stat], compare: true}
{args: [show --oneline], compare: true}
{args: [show --name-only], compare: true}
{args: [show --oneline --stat], compare: true}
{args: [cat-file -t HEAD], compare: true}
{args: [cat-file -p HEAD], compare: true}
{args: [cat-file -s HEAD], compare: true}
{args: [cat-file -e HEAD], compare: true}
{args: [cat-file -e absent], compare: false, exit: 128}
{args: [cat-file -t 'HEAD:file.txt'], compare: true}
{args: [cat-file -p 'HEAD:file.txt'], compare: true}
{args: [cat-file -s 'HEAD:file.txt'], compare: true}
{args: [cat-file -p 'HEAD^{tree}'], compare: true}
{args: [ls-tree HEAD], compare: true}
{args: [ls-tree -r HEAD], compare: true}
{args: [ls-tree --name-only HEAD], compare: true}
{args: [ls-tree -r --name-only HEAD file.txt], compare: true}
{args: [rev-parse --short HEAD], compare: true}
{args: [show HEAD], compare: true}
{args: [show HEAD:file.txt], compare: true}
@@ -451,6 +467,43 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t
if not $equal { $failures += 1; print ($expected | to json); print ($actual | to json) }
}
}
# Reflog-only writes do not require the strict identity that new commits require.
let no_identity = ($root | path join no-identity)
mkdir $no_identity
^git -C $no_identity init -q -b main
'base' | save ($no_identity | path join file.txt)
^git -C $no_identity add file.txt
^git -C $no_identity commit --no-gpg-sign -q -m base
mkdir ($no_identity | path join nested/sub)
'nested' | save ($no_identity | path join nested/sub/file.txt)
^git -C $no_identity add .
let inspection_tree = (^git -C $no_identity write-tree | str trim)
for args in [[ls-tree $inspection_tree] [ls-tree -r $inspection_tree] [ls-tree --name-only $inspection_tree nested] [ls-tree -r --name-only $inspection_tree nested] [ls-tree $inspection_tree nested/sub/file.txt] [cat-file -p $inspection_tree]] {
let expected = (^git -C $no_identity ...$args | complete)
let actual = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($no_identity)::/repo' $component -C /repo ...$args | complete)
let equal = ($expected.exit_code == 0 and $actual.exit_code == 0 and $expected.stdout == $actual.stdout)
print $'nested tree ($args): (if $equal {"PASS"} else {"FAIL"})'
if not $equal { $failures += 1; print ($expected | to json); print ($actual | to json) }
}
^git -C $no_identity reset --hard -q HEAD
^git -C $no_identity config user.useConfigOnly true
let identity_failures = (do {
mut failed = 0
hide-env -i GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL
for args in [[reset --hard HEAD] [checkout -q --detach HEAD] [checkout -q main] [branch -f other HEAD] [update-ref refs/heads/other HEAD]] {
let expected = (^git -C $no_identity ...$args | complete)
let actual = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($no_identity)::/repo' $component -C /repo ...$args | complete)
let equal = ($expected.exit_code == 0 and $actual.exit_code == 0 and $expected.stdout == $actual.stdout)
print $'no identity ($args): (if $equal {"PASS"} else {"FAIL"})'
if not $equal { $failed += 1; print ($expected | to json); print ($actual | to json) }
}
let expected = (^git -C $no_identity commit --allow-empty -m missing | complete)
let actual = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($no_identity)::/repo' $component -C /repo commit --allow-empty -m missing | complete)
if $expected.exit_code != 128 or $actual.exit_code != 128 { $failed += 1; print ($expected | to json); print ($actual | to json) }
print 'no identity commit requires identity: checked'
$failed
})
$failures += $identity_failures
if $failures > 0 {error make {msg: $'($failures) workflow comparisons failed'}}
}
} catch {|err| job kill $server_job; error make {msg: $err.msg} }