diff --git a/host/src/world.rs b/host/src/world.rs index 89aaa12..f740b10 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -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/` symlink. /// `/dev` exposes generated null, zero, and random byte devices in every world. + /// Missing `/etc/gitconfig` defaults to Sausage . Local repository config overrides it. pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result { 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( diff --git a/host/tests/git_compat.rs b/host/tests/git_compat.rs new file mode 100644 index 0000000..f4cbeb1 --- /dev/null +++ b/host/tests/git_compat.rs @@ -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(()) +} diff --git a/host/tests/workspaces.rs b/host/tests/workspaces.rs index f51154c..33a3e88 100644 --- a/host/tests/workspaces.rs +++ b/host/tests/workspaces.rs @@ -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"])?; } diff --git a/userland/exec-tools/Cargo.toml b/userland/exec-tools/Cargo.toml index 9e09c98..4458d8a 100644 --- a/userland/exec-tools/Cargo.toml +++ b/userland/exec-tools/Cargo.toml @@ -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" diff --git a/userland/exec-tools/src/which.rs b/userland/exec-tools/src/which.rs new file mode 100644 index 0000000..5b6cbc0 --- /dev/null +++ b/userland/exec-tools/src/which.rs @@ -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] ..."); + 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)); +} diff --git a/userland/git/README.md b/userland/git/README.md index 2c54f1d..365a211 100644 --- a/userland/git/README.md +++ b/userland/git/README.md @@ -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] [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`. diff --git a/userland/git/src/branches.rs b/userland/git/src/branches.rs index a8f3c4c..c50d385 100644 --- a/userland/git/src/branches.rs +++ b/userland/git/src/branches.rs @@ -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 { + 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, ) -> Result> { - 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 { diff --git a/userland/git/src/inspect.rs b/userland/git/src/inspect.rs new file mode 100644 index 0000000..d878128 --- /dev/null +++ b/userland/git/src/inspect.rs @@ -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) +} diff --git a/userland/git/src/lib.rs b/userland/git/src/lib.rs index 07d8e7b..da2d7cf 100644 --- a/userland/git/src/lib.rs +++ b/userland/git/src/lib.rs @@ -59,6 +59,7 @@ fn identity(name: Option, email: Option) -> Result { 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, }, + #[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, + }, Clone { url: String, path: Option, @@ -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, @@ -1124,6 +1151,7 @@ pub fn run(cli: Cli) -> Result { list, delete, force_delete, + force, show_current, all, rename, @@ -1164,6 +1192,14 @@ pub fn run(cli: Cli) -> Result { 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 { )?; } }, - 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 { 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 { 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 { diff --git a/userland/git/src/local.rs b/userland/git/src/local.rs index 7cb205d..1c56cfe 100644 --- a/userland/git/src/local.rs +++ b/userland/git/src/local.rs @@ -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", )?; } diff --git a/userland/git/test.nu b/userland/git/test.nu index 0ef319b..b90fe1c 100644 --- a/userland/git/test.nu +++ b/userland/git/test.nu @@ -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} }