Merge v3-t73: reflog writes without identity, world default identity, show/cat-file/ls-tree, which (T73)

This commit is contained in:
2026-11-08 02:29:35 +00:00
11 changed files with 450 additions and 30 deletions
+9
View File
@@ -44,6 +44,7 @@ impl World {
/// Copy an optional bundle into a new world. Existing worlds retain their programs.
/// Each `.wasm` tool keeps its independent file and gains a relative `/bin/<name>` symlink.
/// `/dev` exposes generated null, zero, and random byte devices in every world.
/// Missing `/etc/gitconfig` defaults to Sausage <sausage@localhost>. Local repository config overrides it.
pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result<Self> {
let root_dir = dir.join("world");
let host_dir = dir.join("host");
@@ -64,6 +65,14 @@ impl World {
}
}
}
let gitconfig = root_dir.join("etc/gitconfig");
if !gitconfig.try_exists()? {
std::fs::create_dir_all(root_dir.join("etc"))?;
std::fs::write(
gitconfig,
"[user]\n\tname = Sausage\n\temail = sausage@localhost\n",
)?;
}
std::fs::create_dir_all(&host_dir)?;
let mut mounts = Mounts::default();
mounts.mount(
+110
View File
@@ -0,0 +1,110 @@
//! Fresh worlds provide a Git identity and resolve conventional tool names through which.
use sausage::{
exec::{Command, Options, Stream},
world::World,
};
#[tokio::test(flavor = "current_thread")]
async fn default_identity_and_which() -> anyhow::Result<()> {
let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let userland = repo.join("userland");
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
assert!(
std::process::Command::new("cargo")
.args([
"build",
"--release",
"-p",
"git",
"-p",
"sausage-exec-tools",
"--locked"
])
.current_dir(&userland)
.status()?
.success()
);
let target = std::path::PathBuf::from(std::env::var("CARGO_TARGET_DIR")?);
let bundle = tempfile::tempdir()?;
std::fs::create_dir_all(bundle.path().join("bin"))?;
std::fs::create_dir_all(bundle.path().join("src"))?;
for name in ["git", "which"] {
std::fs::copy(
target.join(format!("wasm32-wasip2/release/{name}.wasm")),
bundle.path().join(format!("bin/{name}.wasm")),
)?;
}
let dir = tempfile::tempdir()?;
let world = World::open_with_userland(dir.path(), Some(bundle.path()))?;
std::fs::create_dir_all(dir.path().join("world/repo"))?;
std::fs::write(dir.path().join("world/repo/file.txt"), "base\n")?;
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
for (argv, expected) in [
(vec!["git", "-C", "/repo", "init", "-q", "-b", "main"], ""),
(
vec!["git", "-C", "/repo", "config", "--get", "user.name"],
"Sausage\n",
),
(
vec!["git", "-C", "/repo", "config", "--get", "user.email"],
"sausage@localhost\n",
),
(vec!["git", "-C", "/repo", "add", "."], ""),
(vec!["git", "-C", "/repo", "commit", "-q", "-m", "base"], ""),
(vec!["which", "git"], "/bin/git\n"),
(vec!["which", "-a", "git"], "/bin/git\n/bin/git.wasm\n"),
] {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = exec
.exec(
Command::Argv(argv.iter().map(|s| (*s).into()).collect()),
Options::default(),
&mut |stream, bytes| match stream {
Stream::Stdout => stdout.extend_from_slice(bytes),
Stream::Stderr => stderr.extend_from_slice(bytes),
},
)
.await?;
assert_eq!(code, 0, "{argv:?}: {}", String::from_utf8_lossy(&stderr));
assert_eq!(stdout, expected.as_bytes(), "{argv:?}");
}
std::fs::copy(
dir.path().join("world/bin/git.wasm"),
dir.path().join("world/usr/local/bin/git.wasm"),
)?;
let mut stdout = Vec::new();
assert_eq!(
exec.exec(
Command::Argv(vec!["which".into(), "git".into()]),
Options::default(),
&mut |stream, bytes| if matches!(stream, Stream::Stdout) {
stdout.extend_from_slice(bytes);
}
)
.await?,
0
);
assert_eq!(stdout, b"/usr/local/bin/git.wasm\n");
let config = dir.path().join("world/etc/gitconfig");
std::fs::write(
&config,
"[user]\nname = Custom\nemail = custom@example.test\n",
)?;
let _ = World::open(dir.path())?;
assert_eq!(
std::fs::read_to_string(config)?,
"[user]\nname = Custom\nemail = custom@example.test\n"
);
Ok(())
}
-2
View File
@@ -116,8 +116,6 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
std::fs::write(project.join("base.txt"), "base")?;
for path in [&project, &repo] {
git(path, &["init", "-b", "main"])?;
git(path, &["config", "user.name", "Test"])?;
git(path, &["config", "user.email", "test@example.com"])?;
git(path, &["add", "."])?;
git(path, &["commit", "--no-gpg-sign", "-m", "fixture"])?;
}
+4
View File
@@ -7,6 +7,10 @@ edition = "2024"
name = "env"
path = "src/env.rs"
[[bin]]
name = "which"
path = "src/which.rs"
[[bin]]
name = "timeout"
path = "src/timeout.rs"
+39
View File
@@ -0,0 +1,39 @@
//! Resolve tool names through PATH, with the same .wasm fallback as the host.
use std::path::Path;
fn main() {
let mut args = std::env::args().skip(1).peekable();
let all = args.peek().is_some_and(|arg| arg == "-a");
if all {
args.next();
}
let names: Vec<_> = args.collect();
if names.is_empty() || names.iter().any(|name| name.starts_with('-')) {
eprintln!("usage: which [-a] <name>...");
std::process::exit(2);
}
let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/bin".into());
let mut missing = false;
for name in names {
let paths: Vec<_> = if name.contains('/') {
vec![name.clone()]
} else {
path.split(':')
.map(|dir| Path::new(dir).join(&name).to_string_lossy().into_owned())
.collect()
};
let mut found = false;
'search: for path in paths {
for candidate in [path.clone(), format!("{path}.wasm")] {
if std::fs::metadata(&candidate).is_ok_and(|metadata| metadata.is_file()) {
println!("{candidate}");
found = true;
if !all {
break 'search;
}
}
}
}
missing |= !found;
}
std::process::exit(i32::from(missing));
}
+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} }