From 54229adad29981f7387ade74be2c6c172e0067ea Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 00:44:49 +0000 Subject: [PATCH] git: add workspace coordination forms and conversation trailers --- userland/git/README.md | 24 ++- userland/git/src/changes.rs | 14 +- userland/git/src/lib.rs | 312 +++++++++++++++++++++++++++++----- userland/git/src/network.rs | 52 +++++- userland/git/src/output.rs | 15 ++ userland/git/src/plumbing.rs | 263 ++++++++++++++++++++++++++++ userland/git/src/worktrees.rs | 43 ++++- userland/git/test.nu | 114 ++++++++++++- 8 files changed, 770 insertions(+), 67 deletions(-) create mode 100644 userland/git/src/plumbing.rs diff --git a/userland/git/README.md b/userland/git/README.md index 6a48d9b..3748d83 100644 --- a/userland/git/README.md +++ b/userland/git/README.md @@ -27,9 +27,12 @@ The following commands run from the repository root. ## Native/WASI comparison and import check ```sh -nix develop -c nu userland/git/test.nu /tmp/p1-git/target/wasm32-wasip2/release/git.wasm +nix develop -c nu userland/git/test.nu /tmp/p1-git/target/wasm32-wasip2/release/git.wasm /tmp/p1-git/server-target/debug/git-test-http-server ``` +Build the native HTTP fixture first with the command in the local smart-HTTP section. +The comparison script starts the fixture on an ephemeral port. +It stops the fixture after success or failure. 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. @@ -55,6 +58,8 @@ 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`. +- `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. - `diff HEAD~1`, `diff A..B`, `--name-only`, `--name-status`, and one literal path after `--`. - `branch -a`, `-d`, `-D`, `-m`, and `--show-current`. @@ -70,6 +75,23 @@ It also checks these forms: - 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`. +- `merge-base A B`, `--is-ancestor`, and `rev-list --count A..B`. +- `rev-parse --verify --quiet`, `--abbrev-ref HEAD`, and `symbolic-ref [--quiet] [--short] HEAD`. +- `update-ref []` with an atomic old-value check. +- `worktree add --detach `, `prune`, and `list --porcelain`. +- `checkout --detach --force ` and `clean -fdq`. +- `merge --no-edit -m ` and `--ff-only`. +- `diff --numstat --no-renames`, `--diff-filter=U --name-only`, and `--name-only A B`. +- NUL-separated status, file lists, diff names, and numstat through `-z`. +- `status --porcelain --untracked-files=all` and `ls-files --others --exclude-standard`. +- `branch --list ` and `check-ignore --no-index `. +- `fetch ` with the selected revision in `FETCH_HEAD`. + +If `SAUSAGE_CONVERSATION` exists, `commit` appends its value as a `Sausage-Conversation` trailer. +The trailer follows a blank line after the message body. +Without this variable, `commit` preserves the normal message. +The tests compare both forms with native Git, including commit IDs. +`clean -fdq` preserves tracked files, ignored files, and nested repositories. The log applies its path filter before its count limit. Stashes use ordinary Git commits and the `refs/stash` reflog. diff --git a/userland/git/src/changes.rs b/userland/git/src/changes.rs index b91fdfe..a6447b7 100644 --- a/userland/git/src/changes.rs +++ b/userland/git/src/changes.rs @@ -173,6 +173,13 @@ fn quote(prefix: &str, path: &[u8]) -> String { } // Internal byte paths preserve identity even when the RPC display is lossy. fn compare(old: &Snapshot, new: &Snapshot) -> Vec<(Vec, Option>, Kind)> { + compare_with_renames(old, new, true) +} +fn compare_with_renames( + old: &Snapshot, + new: &Snapshot, + renames: bool, +) -> Vec<(Vec, Option>, Kind)> { let mut deleted: BTreeSet<_> = old .keys() .filter(|p| !new.contains_key(*p)) @@ -187,7 +194,7 @@ fn compare(old: &Snapshot, new: &Snapshot) -> Vec<(Vec, Option>, Kin // Git does not pair empty blobs as renames by default. let empty = gix::objs::compute_hash(entry.id.kind(), gix::objs::Kind::Blob, b"").ok(); - let source = if Some(entry.id) == empty { + let source = if !renames || Some(entry.id) == empty { None } else { deleted.iter().find(|p| old.get(*p) == Some(entry)).cloned() @@ -383,6 +390,9 @@ fn worktree(repo: &gix::Repository, index: &Snapshot, filter: Option<&str>) -> R Ok(snapshot) } pub fn diff(p: DiffParams) -> Result> { + diff_with_renames(p, true) +} +pub fn diff_with_renames(p: DiffParams, renames: bool) -> Result> { if let Some(path) = &p.path { repo::validate_path(path)?; } @@ -425,7 +435,7 @@ pub fn diff(p: DiffParams) -> Result> { } }; let mut result = Vec::new(); - for (path, source, status) in compare(&old, &new) { + for (path, source, status) in compare_with_renames(&old, &new, renames) { let before_path = source.as_ref().unwrap_or(&path); if !matches_path(&path, p.path.as_deref()) && !matches_path(before_path, p.path.as_deref()) { diff --git a/userland/git/src/lib.rs b/userland/git/src/lib.rs index d2cd1f6..169ce75 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 plumbing; mod stash; use clap::{Parser, Subcommand}; #[derive(Parser)] @@ -87,6 +88,39 @@ enum Command { Fetch { #[arg(default_value = "origin")] remote: String, + revision: Option, + }, + MergeBase { + #[arg(long)] + is_ancestor: bool, + a: String, + b: String, + }, + RevList { + #[arg(long)] + count: bool, + revisions: Vec, + }, + SymbolicRef { + #[arg(long)] + short: bool, + name: String, + }, + UpdateRef { + name: String, + new: String, + old: Option, + }, + Clean { + #[arg(short = 'f')] + force: bool, + #[arg(short = 'd')] + directories: bool, + }, + CheckIgnore { + #[arg(long)] + no_index: bool, + paths: Vec, }, Push { #[arg(short = 'u', long)] @@ -113,6 +147,8 @@ enum Command { value: Option, }, RevParse { + #[arg(long)] + verify: bool, #[arg(long)] show_toplevel: bool, #[arg(long)] @@ -124,6 +160,10 @@ enum Command { revisions: Vec, }, Status { + #[arg(long, default_value = "normal")] + untracked_files: String, + #[arg(short = 'z')] + nul: bool, #[arg(short, long)] short: bool, #[arg(long, num_args = 0..=1, default_missing_value = "v1", require_equals = true)] @@ -159,10 +199,16 @@ enum Command { no_gpg_sign: bool, }, Log { + #[arg(long)] + name_only: bool, + #[arg(long)] + no_renames: bool, + #[arg(long)] + diff_merges: Option, #[arg(long)] oneline: bool, - #[arg(short = 'n', default_value = "50")] - limit: usize, + #[arg(short = 'n')] + limit: Option, #[arg(long)] format: Option, #[arg(long)] @@ -176,6 +222,14 @@ enum Command { paths: Vec, }, Diff { + #[arg(long)] + numstat: bool, + #[arg(long)] + no_renames: bool, + #[arg(long)] + diff_filter: Option, + #[arg(short = 'z')] + nul: bool, #[arg(long, alias = "staged")] cached: bool, #[arg(long)] @@ -189,6 +243,8 @@ enum Command { paths: Vec, }, Branch { + #[arg(long)] + list: bool, #[arg(long)] show_current: bool, #[arg(short = 'a')] @@ -208,12 +264,18 @@ enum Command { target: String, }, Checkout { + #[arg(long)] + detach: bool, + #[arg(long, short = 'f')] + force: bool, #[arg(short = 'b')] create: bool, target: Option, paths: Vec, }, Merge { + #[arg(long)] + no_edit: bool, #[arg(long)] ff_only: bool, #[arg(long)] @@ -239,7 +301,14 @@ enum Command { #[command(subcommand)] command: Worktree, }, - LsFiles, + LsFiles { + #[arg(long)] + others: bool, + #[arg(long)] + exclude_standard: bool, + #[arg(short = 'z')] + nul: bool, + }, Reset { #[arg(long, conflicts_with_all = ["hard", "mixed"])] soft: bool, @@ -290,11 +359,14 @@ enum Command { } #[derive(Subcommand)] enum Worktree { + Prune, List { #[arg(long)] porcelain: bool, }, Add { + #[arg(long)] + detach: bool, #[arg(short = 'b')] branch: Option, path: String, @@ -481,12 +553,74 @@ pub fn run(cli: Cli) -> Result { abort, revision, } => return cherry_pick::run(&repository, revision, resume, abort, quiet), - Command::LsFiles => { + Command::MergeBase { a, b, is_ancestor } => { + return plumbing::merge_base(&repository, &a, &b, is_ancestor); + } + Command::RevList { count, revisions } => { + let mut included = std::collections::BTreeSet::new(); + let mut excluded = std::collections::BTreeSet::new(); + for revision in revisions { + let (tip, hidden) = plumbing::range(&repository, &revision)?; + included.extend(plumbing::ancestors(&repository, tip)?); + excluded.extend(hidden); + } + included.retain(|id| !excluded.contains(id)); + if count { + println!("{}", included.len()); + } else { + for id in included { + println!("{id}"); + } + } + } + Command::SymbolicRef { short, name } => { + let reference = repository + .find_reference(name.as_str()) + .map_err(|e| error("symbolic-ref", e))?; + if let gix::refs::TargetRef::Symbolic(target) = reference.target() { + println!( + "{}", + if short { + target.shorten().to_string() + } else { + target.as_bstr().to_string() + } + ); + } else { + if !quiet { + eprintln!("fatal: ref {name} is not a symbolic ref"); + } + return Ok(if quiet { 1 } else { 128 }); + } + } + Command::UpdateRef { name, new, old } => { + plumbing::update_ref(&repository, &name, &new, old.as_deref())? + } + Command::CheckIgnore { no_index, paths } => { + return plumbing::check_ignore(&repository, &paths, no_index); + } + Command::Clean { force, directories } => { + if !force { + return Err(error("clean", "requires -f")); + } + plumbing::clean(&repository, directories, quiet)?; + } + Command::LsFiles { + others, + exclude_standard: _, + nul, + } => { + if others { + for path in changes::status(&repository)?.untracked { + print!("{path}{}", if nul { "\0" } else { "\n" }); + } + return Ok(0); + } let index = repository .index_or_empty() .map_err(|e| error("ls-files", e))?; for entry in index.entries() { - println!("{}", entry.path(&index)); + print!("{}{}", entry.path(&index), if nul { "\0" } else { "\n" }); } } Command::Config { key, value, .. } => { @@ -511,6 +645,7 @@ pub fn run(cli: Cli) -> Result { } } Command::RevParse { + verify, show_toplevel, git_dir, abbrev_ref, @@ -531,10 +666,11 @@ pub fn run(cli: Cli) -> Result { ); } else { for revision in revisions { - let id = repository - .rev_parse_single(revision.as_str()) - .map_err(|e| error("revision", e))? - .to_string(); + let id = match repository.rev_parse_single(revision.as_str()) { + Ok(id) => id.to_string(), + Err(_) if verify && quiet => return Ok(1), + Err(e) => return Err(error("revision", e)), + }; println!("{}", if short { &id[..7] } else { &id }); } } @@ -591,7 +727,7 @@ pub fn run(cli: Cli) -> Result { eprintln!("usage: git commit -m | -F | --amend --no-edit"); return Ok(129); }; - let message = message.trim_end().to_owned(); + let message = plumbing::trailer(&message); if !amend && !allow_empty && merge::pending_parent(&repository)?.is_none() { let status = changes::status(&repository)?; if status.staged.is_empty() @@ -666,6 +802,8 @@ pub fn run(cli: Cli) -> Result { })?); } Command::Status { + untracked_files, + nul, short, porcelain, branch, @@ -681,6 +819,13 @@ pub fn run(cli: Cli) -> Result { .collect(); let mut untracked = std::collections::BTreeSet::new(); for path in status.untracked { + if untracked_files == "no" { + continue; + } + if untracked_files == "all" { + untracked.insert(path); + continue; + } let collapsed = path .match_indices('/') .map(|(i, _)| &path[..=i]) @@ -714,7 +859,11 @@ pub fn run(cli: Cli) -> Result { ); } let mut rows = std::collections::BTreeMap::new(); + let mut sources = std::collections::BTreeMap::new(); for c in status.staged { + if let Some(source) = c.old_path { + sources.insert(c.path.clone(), source); + } rows.entry(c.path).or_insert([' '; 2])[0] = code(&c.status); } for c in status.unstaged { @@ -729,12 +878,24 @@ pub fn run(cli: Cli) -> Result { for untracked in [false, true] { for (p, c) in &rows { if (c == &['?', '?']) == untracked { - println!("{}{} {p}", c[0], c[1]); + if let Some(source) = sources.get(p) { + if nul { + print!("{}{} {p}\0{source}\0", c[0], c[1]); + } else { + println!("{}{} {source} -> {p}", c[0], c[1]); + } + } else { + print!("{}{} {p}{}", c[0], c[1], if nul { "\0" } else { "\n" }); + } } } } } Command::Diff { + numstat, + no_renames, + diff_filter, + nul, cached, stat, name_only, @@ -748,22 +909,41 @@ pub fn run(cli: Cli) -> Result { } else { (revisions.first().cloned(), revisions.get(1).cloned()) }; - let diffs = changes::diff(changes::DiffParams { - repo: path, - from, - to, - staged: Some(cached), - path: paths.first().cloned(), - context: None, - })?; + if diff_filter.as_deref() == Some("U") { + for p in changes::status(&repository)?.conflicted { + print!("{p}{}", if nul { "\0" } else { "\n" }); + } + return Ok(0); + } + let diffs = changes::diff_with_renames( + changes::DiffParams { + repo: path, + from, + to, + staged: Some(cached), + path: paths.first().cloned(), + context: None, + }, + !no_renames, + )?; if name_only || name_status { for diff in diffs { if name_status { - println!("{}\t{}", code(&diff.status), diff.path); + print!( + "{}{}{}{}", + code(&diff.status), + if nul { "\0" } else { "\t" }, + diff.path, + if nul { "\0" } else { "\n" } + ); } else { - println!("{}", diff.path); + print!("{}{}", diff.path, if nul { "\0" } else { "\n" }); } } + } else if numstat { + for diff in &diffs { + output::numstat(diff, nul); + } } else if stat { output::stat(&diffs, false); } else { @@ -773,6 +953,9 @@ pub fn run(cli: Cli) -> Result { } } Command::Log { + name_only, + no_renames, + diff_merges, oneline, limit, format, @@ -782,13 +965,10 @@ pub fn run(cli: Cli) -> Result { revision, paths, } => { - let commit = repository - .rev_parse_single(revision.as_deref().unwrap_or("HEAD")) - .map_err(|e| error("log", e))? - .object() - .map_err(|e| error("log", e))? - .peel_to_commit() - .map_err(|e| error("log", e))?; + let limit = limit.unwrap_or(usize::MAX); + let (tip, excluded) = + plumbing::range(&repository, revision.as_deref().unwrap_or("HEAD"))?; + let commit = repository.find_commit(tip).map_err(|e| error("log", e))?; let ids = if let Some(path) = paths.first() { history::paths(&repository, commit.id, path)? } else { @@ -800,16 +980,28 @@ pub fn run(cli: Cli) -> Result { )) .all() .map_err(|e| error("log", e))? + .filter(|info| { + info.as_ref() + .map_or(true, |info| !excluded.contains(&info.id)) + }) .take(limit) .map(|info| info.map(|info| info.id).map_err(|e| error("log", e))) .collect::>>()? }; let mut first = true; - for id in ids.into_iter().take(limit) { + for id in ids + .into_iter() + .filter(|id| !excluded.contains(id)) + .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(); - let diffs = if patch || stat || !paths.is_empty() { + let diffs = if (patch || stat || name_only || !paths.is_empty()) + && (decoded.parents.len() <= 1 + || diff_merges.as_deref() == Some("first-parent") + || !name_only) + { let old = match decoded.parents.first() { Some(id) => id.to_string(), None => repository @@ -817,14 +1009,17 @@ pub fn run(cli: Cli) -> Result { .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, - })? + changes::diff_with_renames( + changes::DiffParams { + repo: path.clone(), + from: Some(old), + to: Some(c.id.to_string()), + staged: None, + path: paths.first().cloned(), + context: None, + }, + !no_renames, + )? } else { Vec::new() }; @@ -867,7 +1062,7 @@ pub fn run(cli: Cli) -> Result { } else { format!(" ({})", labels.join(", ")) }; - let text = template + let text = plumbing::format(template, &message) .replace("%H", &c.id.to_string()) .replace("%h", &c.id.to_string()[..7]) .replace("%s", message.lines().next().unwrap_or("")) @@ -897,6 +1092,12 @@ pub fn run(cli: Cli) -> Result { output::commit(&c)?; first = false; } + if name_only && !diffs.is_empty() { + println!(); + for diff in &diffs { + println!("{}", diff.path); + } + } if patch || stat { println!(); if stat { @@ -911,6 +1112,7 @@ pub fn run(cli: Cli) -> Result { } } Command::Branch { + list, delete, force_delete, show_current, @@ -938,7 +1140,8 @@ pub fn run(cli: Cli) -> Result { } return Ok(0); } - if let Some(name) = name { + if let Some(name) = name.as_ref().filter(|_| !list) { + let name = name.clone(); if delete || force_delete { let id = branches::resolve(&repository, &name)?.to_string(); branches::delete( @@ -964,6 +1167,7 @@ pub fn run(cli: Cli) -> Result { )?; } } else { + let pattern = name.as_deref(); let head = head_info(&repository)?; for branch in repository .references() @@ -978,6 +1182,12 @@ pub fn run(cli: Cli) -> Result { { continue; } + if list + && pattern + .is_some_and(|pattern| branch.name().shorten().to_string() != pattern) + { + continue; + } println!( "{} {}", if head.branch.as_deref() @@ -997,6 +1207,8 @@ pub fn run(cli: Cli) -> Result { } } Command::Checkout { + detach, + force, create, target, mut paths, @@ -1021,9 +1233,14 @@ pub fn run(cli: Cli) -> Result { &repository, branches::CheckoutParams { repo: path, - target: required(target, "checkout target")?, + target: if detach { + branches::resolve(&repository, &required(target, "checkout target")?)? + .to_string() + } else { + required(target, "checkout target")? + }, create: Some(create), - force: None, + force: Some(force), }, )?; } @@ -1040,6 +1257,7 @@ pub fn run(cli: Cli) -> Result { )?; } Command::Merge { + no_edit: _, ff_only, no_ff, no_commit, @@ -1058,7 +1276,7 @@ pub fn run(cli: Cli) -> Result { merge::MergeParams { repo: path.clone(), rev: required(revision, "merge")?, - message, + message: message.map(|m| format!("{}\n", m.trim_end())), ff: Some(if ff_only { merge::FastForward::Only } else if no_ff { @@ -1154,6 +1372,7 @@ pub fn run(cli: Cli) -> Result { } } Command::Worktree { command } => match command { + Worktree::Prune => worktrees::prune(&repository)?, Worktree::List { porcelain } => { let common = repository .common_dir() @@ -1185,6 +1404,9 @@ pub fn run(cli: Cli) -> Result { .unwrap_or(0) + 2; for p in paths { + let p = p + .canonicalize() + .map_err(|e| error("worktree list path", e))?; let linked = repo::open(&p.to_string_lossy())?; let head = head_info(&linked)?; let id = head.id.unwrap_or_else(|| "0".repeat(40)); @@ -1206,6 +1428,7 @@ pub fn run(cli: Cli) -> Result { } } Worktree::Add { + detach, branch, path: target, revision, @@ -1226,6 +1449,7 @@ pub fn run(cli: Cli) -> Result { .to_string_lossy() .into_owned(), branch, + detached: Some(detach), from: revision, adopt: None, }, @@ -1324,7 +1548,7 @@ pub fn run(cli: Cli) -> Result { } } } - Command::Fetch { remote } => network::fetch(&path, &remote)?, + Command::Fetch { remote, revision } => network::fetch(&path, &remote, revision.as_deref())?, Command::Push { set_upstream, force, diff --git a/userland/git/src/network.rs b/userland/git/src/network.rs index bbdb2d2..b16e264 100644 --- a/userland/git/src/network.rs +++ b/userland/git/src/network.rs @@ -16,10 +16,10 @@ struct FetchResult { copied_objects: Option, updated_refs: Option>, } -pub(crate) fn fetch(path: &str, remote: &str) -> Result<()> { +pub(crate) fn fetch(path: &str, remote: &str, revision: Option<&str>) -> Result<()> { let mut repo = repo::open(path)?; repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable; - perform(&repo, remote, None, false)?; + perform(&repo, remote, None, false, revision)?; Ok(()) } pub(crate) fn clone(url: &str, path: &str) -> Result<()> { @@ -32,7 +32,7 @@ pub(crate) fn clone(url: &str, path: &str) -> Result<()> { remotes::set(&repo, "origin", url, true)?; let mut repo = repo::open(&name)?; repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable; - let fetched = perform(&repo, "origin", None, false)?; + let fetched = perform(&repo, "origin", None, false, None)?; let branch = fetched .default_branch .ok_or_else(|| error("clone", "server HEAD does not name a branch"))?; @@ -61,9 +61,10 @@ fn perform( remote_name: &str, depth: Option, prune: bool, + revision: Option<&str>, ) -> Result { remotes::validate_name(remote_name)?; - let remote = repo + let mut remote = repo .find_remote(remote_name) .map_err(|_| error("fetch", "configured remote not found"))?; let url = remote @@ -83,6 +84,21 @@ fn perform( )); } } + if let Some(revision) = revision { + let source = if revision.starts_with("refs/") || revision.len() == 40 { + revision.to_owned() + } else { + format!("refs/heads/{revision}") + }; + let spec = if let Some(branch) = source.strip_prefix("refs/heads/") { + format!("+{source}:refs/remotes/{remote_name}/{branch}") + } else { + source + }; + remote + .replace_refspecs([spec.as_bytes().as_bstr()], gix::remote::Direction::Fetch) + .map_err(|e| error("fetch refspec", e))?; + } let transport: Box = if crate::ssh::is_ssh(&url) { Box::new(crate::ssh::connection( @@ -137,6 +153,13 @@ fn perform( _ => {} } } + let fetch_heads: Vec<_> = refs + .iter() + .filter_map(|r| { + let (name, id, _) = r.unpack(); + id.map(|id| (name.to_string(), id.to_string())) + }) + .collect(); let advertised_refs = refs.len(); let empty = heads.is_empty() && refs.iter().all(|r| r.unpack().1.is_none()); if let Some(depth) = depth { @@ -170,7 +193,26 @@ fn perform( )); } } - if let Some(branch) = default_branch.as_deref().filter(|b| heads.contains(*b)) { + let fetch_head = fetch_heads + .iter() + .filter(|(name, _)| { + revision.map_or(name.starts_with("refs/heads/"), |r| { + name == r || name == &format!("refs/heads/{r}") + }) + }) + .map(|(name, id)| { + format!( + "{id}\t\tbranch '{}' of {url}\n", + name.trim_start_matches("refs/heads/") + ) + }) + .collect::(); + std::fs::write(repo.git_dir().join("FETCH_HEAD"), fetch_head) + .map_err(|e| error("FETCH_HEAD", e))?; + if let Some(branch) = default_branch + .as_deref() + .filter(|b| revision.is_none() && heads.contains(*b)) + { use gix::refs::{ Target, transaction::{Change, PreviousValue, RefEdit}, diff --git a/userland/git/src/output.rs b/userland/git/src/output.rs index 5e892c3..b06d5fa 100644 --- a/userland/git/src/output.rs +++ b/userland/git/src/output.rs @@ -122,6 +122,21 @@ fn label(kind: &Kind) -> &'static str { Kind::Renamed => "renamed:", } } +pub(crate) fn numstat(diff: &FileDiff, nul: bool) { + let hunks = diff.patch.lines().skip_while(|l| !l.starts_with("@@")); + let added = hunks.clone().filter(|l| l.starts_with('+')).count(); + let removed = hunks.filter(|l| l.starts_with('-')).count(); + let (added, removed) = if diff.binary { + ("-".into(), "-".into()) + } else { + (added.to_string(), removed.to_string()) + }; + print!( + "{added}\t{removed}\t{}{}", + diff.path, + if nul { "\0" } else { "\n" } + ); +} pub(crate) fn stat(diffs: &[FileDiff], summary: bool) { summarize(diffs, true, summary); } diff --git a/userland/git/src/plumbing.rs b/userland/git/src/plumbing.rs new file mode 100644 index 0000000..539ff47 --- /dev/null +++ b/userland/git/src/plumbing.rs @@ -0,0 +1,263 @@ +//! Revision sets, reference transactions, and ignored-path queries for coordination. +use crate::{Result, branches, repo::error}; +use gix::bstr::ByteSlice; +use std::collections::BTreeSet; + +pub(crate) fn ancestors( + repo: &gix::Repository, + tip: gix::ObjectId, +) -> Result> { + repo.find_commit(tip) + .map_err(|e| error("revision", e))? + .ancestors() + .use_commit_graph(false) + .all() + .map_err(|e| error("revision walk", e))? + .map(|info| info.map(|i| i.id).map_err(|e| error("revision walk", e))) + .collect() +} +pub(crate) fn range( + repo: &gix::Repository, + input: &str, +) -> Result<(gix::ObjectId, BTreeSet)> { + let (from, to) = input.split_once("..").unwrap_or(("", input)); + let tip = branches::resolve(repo, if to.is_empty() { "HEAD" } else { to })?; + let excluded = if input.contains("..") { + ancestors( + repo, + branches::resolve(repo, if from.is_empty() { "HEAD" } else { from })?, + )? + } else { + BTreeSet::new() + }; + Ok((tip, excluded)) +} +pub(crate) fn merge_base( + repo: &gix::Repository, + a: &str, + b: &str, + is_ancestor: bool, +) -> Result { + let a = branches::resolve(repo, a)?; + let b = branches::resolve(repo, b)?; + let base = repo.merge_base(a, b).map_err(|e| error("merge-base", e))?; + if is_ancestor { + return Ok(if base.is_some_and(|id| id.detach() == a) { + 0 + } else { + 1 + }); + } + if let Some(base) = base { + println!("{base}"); + Ok(0) + } else { + Ok(1) + } +} +pub(crate) fn update_ref( + repo: &gix::Repository, + name: &str, + new: &str, + old: Option<&str>, +) -> Result<()> { + use gix::refs::{ + Target, + transaction::{Change, PreviousValue, RefEdit}, + }; + let expected = match old { + Some(value) if value == "0".repeat(40) => PreviousValue::MustNotExist, + Some(value) => PreviousValue::MustExistAndMatch(Target::Object( + repo.rev_parse_single(value) + .map_err(|e| error("old revision", e))? + .detach(), + )), + None => PreviousValue::Any, + }; + branches::edit_refs( + repo, + vec![RefEdit { + name: name.try_into().map_err(|e| error("reference", e))?, + deref: true, + change: Change::Update { + log: Default::default(), + expected, + new: Target::Object( + repo.rev_parse_single(new) + .map_err(|e| error("new revision", e))? + .detach(), + ), + }, + }], + )?; + Ok(()) +} +pub(crate) fn trailer(message: &str) -> String { + if let Ok(value) = std::env::var("SAUSAGE_CONVERSATION") { + let message = message.trim_end(); + let line = format!("Sausage-Conversation: {value}"); + if message.lines().any(|l| l == line) { + return message.into(); + } + let last = message.rsplit("\n\n").next().unwrap_or(message); + let has_trailers = message.contains("\n\n") + && last + .lines() + .all(|l| l.split_once(':').is_some_and(|(k, _)| !k.contains(' '))); + format!( + "{message}{}{line}", + if has_trailers { "\n" } else { "\n\n" } + ) + } else { + message.trim_end().into() + } +} +pub(crate) fn format(template: &str, message: &str) -> String { + let trailer = "%(trailers:key=Sausage-Conversation,valueonly)"; + let values = message + .rsplit_once("\n\n") + .map(|(_, block)| { + block + .lines() + .filter_map(|line| { + line.split_once(':') + .filter(|(key, _)| key.eq_ignore_ascii_case("Sausage-Conversation")) + .map(|(_, value)| format!("{}\n", value.trim())) + }) + .collect::() + }) + .unwrap_or_default(); + let text = template.replace(trailer, &values); + let mut output = String::new(); + let mut rest = text.as_str(); + while let Some(i) = rest.find("%x") { + output.push_str(&rest[..i]); + rest = &rest[i..]; + if let Some(hex) = rest.get(2..4).and_then(|s| u8::from_str_radix(s, 16).ok()) { + output.push(hex as char); + rest = &rest[4..]; + } else { + output.push_str("%x"); + rest = &rest[2..]; + } + } + output.push_str(rest); + output +} +pub(crate) fn check_ignore( + repo: &gix::Repository, + paths: &[String], + no_index: bool, +) -> Result { + let index = repo.index_or_empty().map_err(|e| error("index", e))?; + let mut excludes = repo + .excludes( + &index, + None, + gix::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped, + ) + .map_err(|e| error("excludes", e))?; + let mut found = false; + for path in paths { + crate::repo::validate_path(path)?; + if !no_index + && index + .entries() + .iter() + .any(|e| e.path(&index) == path.as_bytes().as_bstr()) + { + continue; + } + if excludes + .at_entry(path.as_str(), None) + .map_err(|e| error("ignore", e))? + .is_excluded() + { + println!("{path}"); + found = true; + } + } + Ok(if found { 0 } else { 1 }) +} +pub(crate) fn clean(repo: &gix::Repository, directories: bool, quiet: bool) -> Result<()> { + let root = crate::repo::worktree(repo)?; + let index = repo.index_or_empty().map_err(|e| error("index", e))?; + let mut excludes = repo + .excludes( + &index, + None, + gix::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped, + ) + .map_err(|e| error("excludes", e))?; + fn walk( + root: &std::path::Path, + relative: &std::path::Path, + index: &gix::index::State, + excludes: &mut gix::AttributeStack<'_>, + directories: bool, + quiet: bool, + ) -> Result<()> { + for entry in + std::fs::read_dir(root.join(relative)).map_err(|e| error("clean directory", e))? + { + let entry = entry.map_err(|e| error("clean entry", e))?; + if entry.file_name() == ".git" { + continue; + } + let path = relative.join(entry.file_name()); + let name = path.to_string_lossy(); + let dir = entry + .file_type() + .map_err(|e| error("clean type", e))? + .is_dir(); + let tracked = index.entries().iter().any(|e| { + let p = e.path(index); + p == name.as_bytes().as_bstr() + || (dir && p.starts_with(format!("{name}/").as_bytes())) + }); + if excludes + .at_entry( + name.as_ref(), + if dir { + Some(gix::index::entry::Mode::DIR) + } else { + None + }, + ) + .map_err(|e| error("clean ignore", e))? + .is_excluded() + { + continue; + } + if dir { + if entry.path().join(".git").exists() || (!directories && !tracked) { + continue; + } + walk(root, &path, index, excludes, directories, quiet)?; + if directories + && !tracked + && std::fs::read_dir(entry.path()) + .map_err(|e| error("clean directory", e))? + .next() + .is_none() + { + std::fs::remove_dir(entry.path()).map_err(|e| error("clean directory", e))?; + } + } else if !tracked { + std::fs::remove_file(entry.path()).map_err(|e| error("clean file", e))?; + if !quiet { + println!("Removing {name}"); + } + } + } + Ok(()) + } + walk( + root, + std::path::Path::new(""), + &index, + &mut excludes, + directories, + quiet, + ) +} diff --git a/userland/git/src/worktrees.rs b/userland/git/src/worktrees.rs index 0dbb4be..ba8a89b 100644 --- a/userland/git/src/worktrees.rs +++ b/userland/git/src/worktrees.rs @@ -14,6 +14,7 @@ use std::{ /// Attach a linked worktree, or adopt existing bytes onto an unborn branch. #[derive(serde::Serialize, serde::Deserialize)] pub(crate) struct AddParams { + pub detached: Option, /// Repository name or absolute worktree/bare path under /repos/ or /plugins/. pub repo: String, /// Absolute worktree path under /plugins/ or /repos/, without dot segments. @@ -150,10 +151,28 @@ pub(crate) struct AttachParams { /// Existing short local branch name. pub branch: String, } +pub(crate) fn prune(repo: &gix::Repository) -> Result<()> { + let base = repo.common_dir().join("worktrees"); + if !base.exists() { + return Ok(()); + } + for entry in fs::read_dir(base).map_err(|e| error("worktree prune", e))? { + let dir = entry.map_err(|e| error("worktree prune", e))?.path(); + if dir.join("locked").exists() { + continue; + } + let gitdir = read(&dir.join("gitdir"))?; + if !Path::new(gitdir.trim()).exists() { + fs::remove_dir_all(dir).map_err(|e| error("worktree prune", e))?; + } + } + Ok(()) +} pub(crate) fn attach(repo: &gix::Repository, p: AttachParams) -> Result { add_with( repo, AddParams { + detached: None, repo: p.repo, path: p.path, branch: p.branch, @@ -170,20 +189,24 @@ fn add_with(repo: &gix::Repository, p: AddParams, keep_files: bool) -> Result Result $deadline { job kill $server_job; error make {msg: 'HTTP fixture did not start'} } } + let url = $'http://127.0.0.1:(open --raw $ready | str trim)/coord.git' + try { let envs = {GIT_AUTHOR_NAME: 'Agent', GIT_AUTHOR_EMAIL: 'agent@example.test', GIT_COMMITTER_NAME: 'Agent', GIT_COMMITTER_EMAIL: 'agent@example.test', GIT_AUTHOR_DATE: '1700000000 +0000', GIT_COMMITTER_DATE: '1700000000 +0000', GIT_CONFIG_NOSYSTEM: '1', GIT_EDITOR: 'true', HOME: '/tmp/p1-git/empty-home', LC_ALL: 'C', TZ: 'UTC'} mkdir $envs.HOME with-env $envs { @@ -29,6 +37,22 @@ def main [component: path] { {args: [status --porcelain=v1], compare: true} {args: [status], compare: true} {args: [ls-files], compare: true} + {publish: true} + {args: [remote add fixture $url], compare: true, state: true} + {args: [fetch fixture main], compare: true, state: true} + {args: [rev-parse FETCH_HEAD], compare: true} + {args: [rev-parse --verify --quiet HEAD], compare: true} + {args: [rev-parse --verify --quiet absent], compare: true, exit: 1} + {args: [rev-parse --abbrev-ref HEAD], compare: true} + {args: [symbolic-ref HEAD], compare: true} + {args: [symbolic-ref --quiet --short HEAD], compare: true} + {args: [branch --list main], compare: true} + {args: [branch --list absent], compare: true} + {args: [worktree add --detach ../detached HEAD], compare: true, state: true} + {args: [worktree list --porcelain], compare: true, normalize_paths: true} + {remove: '../detached'} + {args: [worktree prune], compare: true, state: true} + {args: [worktree list --porcelain], compare: true, normalize_paths: true} {args: [branch --show-current], compare: true} {args: [branch -a], compare: true} {args: [log -n 1], compare: true} @@ -49,6 +73,21 @@ def main [component: path] { {args: [rev-parse HEAD HEAD~1], compare: true} {args: [diff HEAD~1], compare: true} {args: [diff HEAD~1..HEAD], compare: true} + {args: [merge-base HEAD~1 HEAD], compare: true} + {args: [merge-base --is-ancestor HEAD~1 HEAD], compare: true} + {args: [merge-base --is-ancestor HEAD HEAD~1], compare: true, exit: 1} + {args: [rev-list --count HEAD~1..HEAD], compare: true} + {args: [rev-list --count HEAD..HEAD], compare: true} + {args: [log --oneline HEAD~1..HEAD], compare: true} + {args: [log --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00' HEAD~1..HEAD], compare: true} + {args: [diff --name-only HEAD~1 HEAD], compare: true} + {args: [diff --name-only -z HEAD~1 HEAD], compare: true} + {args: [diff --numstat --no-renames HEAD~1 HEAD], compare: true} + {args: [diff --numstat --no-renames -z HEAD~1 HEAD], compare: true} + {args: [update-ref refs/heads/transaction HEAD], compare: true, state: true} + {args: [update-ref refs/heads/transaction HEAD~1 HEAD], compare: true, state: true} + {args: [update-ref refs/heads/transaction HEAD HEAD], compare: true, exit: 128, state: true} + {args: [branch -D transaction], compare: true, state: 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} @@ -59,7 +98,7 @@ def main [component: path] { {args: [branch disposable], compare: true} {args: [branch -q -d disposable], compare: true} {args: [switch main], compare: true} - {args: [merge topic], compare: true} + {args: [merge --ff-only topic], compare: true, state: true} {write: 'changed\n', file: 'file.txt'} {args: [status --short], compare: true} {args: [diff], compare: true} @@ -72,6 +111,8 @@ def main [component: path] { {args: [commit -am conflict], compare: true} {args: [switch main], compare: true} {args: [merge topic], compare: true, exit: 1} + {args: [diff --diff-filter=U --name-only], compare: true} + {args: [diff --diff-filter=U --name-only -z], compare: true} {args: [status --short], compare: true} {args: [merge --abort], compare: true} {args: [merge -q topic], compare: true, exit: 1} @@ -113,7 +154,9 @@ def main [component: path] { {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: [merge --no-ff -q --no-edit -m landed agent-switch], compare: true, state: true} + {args: [log --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00' HEAD~1..HEAD], compare: true} + {args: [merge --ff-only agent-switch], compare: true, state: true} {args: [show --stat], compare: true} {args: [commit --quiet --allow-empty -m quiet], compare: true} {args: [init], compare: true, normalize_path: true, state: true} @@ -217,6 +260,8 @@ def main [component: path] { {args: [switch rework], compare: true} {write: 'ours\n', file: 'agent.txt'} {args: [commit -am ours], compare: true} + {args: [merge-base HEAD picks], compare: true} + {args: [merge --ff-only picks], compare: true, exit: 128, state: true} {args: [cherry-pick picks], compare: true, exit: 1, state: true} {args: [status --short], compare: true} {args: [cherry-pick --abort], compare: true, state: true} @@ -227,20 +272,73 @@ def main [component: path] { {args: [log --oneline -n 3], compare: true} {args: [status --short], compare: true} {args: [cherry-pick --abort], compare: true, exit: 128, state: true} + {binary: true, file: 'binary.dat'} + {write: '+++ plus\n--- minus\n', file: 'counts.txt'} + {args: [add binary.dat counts.txt], compare: true} + {args: [diff --cached --numstat --no-renames], compare: true} + {args: [diff --cached --numstat --no-renames -z], compare: true} + {args: [reset --hard HEAD], compare: true, state: true} + {move: 'agent.txt', to: 'renamed.txt'} + {args: [add -A], compare: true} + {args: [status --porcelain -z], compare: true} + {args: [diff --cached --numstat --no-renames], compare: true} + {args: [diff --cached --numstat --no-renames -z], compare: true} + {args: [reset --hard HEAD], compare: true, state: true} + {write: 'ignored/\n*.tmp\n', file: '.gitignore'} + {args: [add .gitignore], compare: true} + {args: [commit -m ignores], compare: true} + {write: 'keep\n', file: 'ignored/keep.tmp'} + {write: 'loose\n', file: 'loose/file.txt'} + {write: 'loose\n', file: 'loose.txt'} + {args: [check-ignore --no-index ignored/keep.tmp], compare: true} + {args: [check-ignore --no-index file.txt], compare: true, exit: 1} + {args: [status --porcelain --untracked-files=all], compare: true} + {args: [status --porcelain --untracked-files=all -z], compare: true} + {args: [ls-files --others --exclude-standard], compare: true} + {args: [ls-files --others --exclude-standard -z], compare: true} + {write: 'raw path\n', file: "tab\tand\nnewline.txt"} + {args: [status --porcelain --untracked-files=all -z], compare: true} + {args: [ls-files --others --exclude-standard -z], compare: true} + {args: [clean -fdq], compare: true, state: true} + {args: [check-ignore --no-index ignored/keep.tmp], compare: true} + {write: 'dirty\n', file: 'agent.txt'} + {args: [checkout --detach --force HEAD~1], compare: true, state: true} + {args: [symbolic-ref --quiet HEAD], compare: true, exit: 1} + {args: [symbolic-ref HEAD], compare: true, exit: 128} + {args: [rev-parse --abbrev-ref HEAD], compare: true} + {args: [switch rework], compare: true} + {args: [commit --allow-empty -m trailer], native_args: [commit --allow-empty -m 'trailer\n\nSausage-Conversation: conversation-34'], conversation: conversation-34, compare: true, state: true} + {args: [log -n 1 --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00'], compare: true} + {args: [commit --allow-empty -m no-trailer], compare: true, state: true} + {args: [log --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00' HEAD~3..HEAD], compare: true} {args: [status --bad-option], compare: true, exit: 129} {args: [not-a-command], compare: true, exit: 1} ] mut failures = 0 for step in $steps { if 'write' in $step { - for side in [native wasi] { $step.write | str replace --all '\n' (char newline) | save --force ($root | path join $side repo $step.file) } + for side in [native wasi] { + let file = ($root | path join $side repo $step.file) + mkdir ($file | path dirname) + $step.write | str replace --all '\n' (char newline) | save --force $file + } continue } + if 'binary' in $step { for side in [native wasi] { 0x[610062] | save --force ($root | path join $side repo $step.file) }; continue } + if 'move' in $step { for side in [native wasi] { mv ($root | path join $side repo $step.move) ($root | path join $side repo $step.to) }; continue } + if 'publish' in $step { + let result = (^git clone --bare ($root | path join native repo) ($server_root | path join coord.git) | complete) + if $result.exit_code != 0 { error make {msg: $result.stderr} } + continue + } + if 'remove' in $step { for side in [native wasi] { rm -rf ($root | path join $side repo $step.remove) }; continue } let args = $step.args - 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 native_args = ($step.native_args? | default $args | each {|arg| $arg | into string | str replace --all '\n' (char newline)}) + let native = (^git -C ($root | path join native repo) ...$native_args | complete) + let extra_env = if 'conversation' in $step { [--env $'SAUSAGE_CONVERSATION=($step.conversation)'] } else { [] } + 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 ...$extra_env $component -C /repo ...$args | complete) let expected = ($step.exit? | default 0) - let native_stdout = if ($step.normalize_path? | default false) { $native.stdout | str replace --all ($root | path join native repo) '/repo' } else { $native.stdout } + let native_stdout = if ($step.normalize_paths? | default false) { $native.stdout | str replace --all ($root | path join native) '' } else 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 { @@ -299,4 +397,6 @@ def main [component: path] { if not $equal { $failures += 1; print ($result | to json) } if $failures > 0 {error make {msg: $'($failures) workflow comparisons failed'}} } + } catch {|err| job kill $server_job; error make {msg: $err.msg} } + job kill $server_job }