From 6daa53cca40f8d93667651aeaa76c7a5f4dfca8d Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 19:07:28 +0000 Subject: [PATCH] git: match native agent workflows --- userland/git/README.md | 47 ++- userland/git/src/changes.rs | 12 + userland/git/src/cherry_pick.rs | 180 +++++++++ userland/git/src/history.rs | 77 ++++ userland/git/src/lib.rs | 651 ++++++++++++++++++++++++++++---- userland/git/src/local.rs | 283 ++++++++++++++ userland/git/src/output.rs | 22 +- userland/git/src/remotes.rs | 12 +- userland/git/src/staging.rs | 14 +- userland/git/src/stash.rs | 227 +++++++++++ userland/git/test.nu | 191 +++++++++- 11 files changed, 1641 insertions(+), 75 deletions(-) create mode 100644 userland/git/src/cherry_pick.rs create mode 100644 userland/git/src/history.rs create mode 100644 userland/git/src/local.rs create mode 100644 userland/git/src/stash.rs diff --git a/userland/git/README.md b/userland/git/README.md index 85babeb..810cbc6 100644 --- a/userland/git/README.md +++ b/userland/git/README.md @@ -25,7 +25,10 @@ The script checks that the component imports only WASI interfaces. It creates separate native and WASI repositories under `/tmp/p1-git/`. It fixes both identities and timestamps. It compares stdout and exit codes without filtering command output. -Initialization and worktree listings compare exit codes only because their paths differ. +The initialization check normalizes only its repository path. +Worktree listings compare exit codes only because their paths differ. +Mutation checks also compare repository state through native Git on both fixtures. +They compare status, commit IDs, parents, authors, diffs, and references. The script retains its fixtures for inspection. `wasmtime run` needs `-S http`, including for local operations. @@ -34,6 +37,48 @@ The component uses WASI's `exit-with-code`, not a custom host import. The world host must enable that optional WASI function. The tests pass explicit `-C` paths. +## Agent command forms + +The comparison suite covers per-command `-c key=value` overrides and quiet local commands. +It checks multiple revisions, detached status, branch deletion messages, and merge commit output. +It also checks these forms: + +- `status -sb` and `status --porcelain=v1`. +- `log -n`, `--oneline`, `-p`, `--stat`, and one literal path after `--`. +- `log --format` and `--pretty=format:` with `%H %h %s %an %ae %ad %d`. +- `show --stat`, `show HEAD:path`, and merge commits without a combined diff. +- `diff HEAD~1`, `diff A..B`, `--name-only`, `--name-status`, and one literal path after `--`. +- `branch -a`, `-d`, `-D`, `-m`, and `--show-current`. +- `checkout -b`, `switch -c`, and `merge --no-ff`. +- `rev-parse --short HEAD`, `ls-files`, and `config --get`. +- Quiet `init`, `commit`, branch deletion, `checkout`, `switch`, `merge`, and `rebase`. +- `init` in an existing repository without replacement of HEAD or configuration. +- `reset --soft`, `reset --hard`, mixed `reset HEAD~1`, and `reset HEAD `. +- `commit --amend -m`, `commit --amend --no-edit`, and `commit -F`. +- `checkout `, `checkout -- `, `restore `, and `restore --staged`. +- Bare `stash`, `stash push`, `-m`, `list`, `pop`, `apply`, and `drop`. +- Quiet stash push, pop, and drop. +- Lightweight tags, `tag -a -m`, bare `tag`, `--list`, and `-d`. +- `remote -v`, `add`, `remove`, `set-url`, and `get-url`. +- Clean `cherry-pick` and conflict resolution through `--continue` or `--abort`. + +The log applies its path filter before its count limit. +Stashes use ordinary Git commits and the `refs/stash` reflog. +Stash accepts only the listed subcommands. +Cherry-pick conflicts use `CHERRY_PICK_HEAD` and `MERGE_MSG`. + +### Unsupported forms + +These forms return a usage error with exit code 129 and empty stdout. +The suite checks the usage error instead of native Git's successful operation. + +- `log --graph --oneline --all`. +- `show` of a merge that changes the same path relative to every parent, which requires a combined diff. + +The CLI supports textual diffs and literal paths, not Git's complete pathspec language. +The format options support the placeholders above, not Git's complete pretty-format language. +The suite compares stdout and exit codes. It does not compare stderr text. + ## Local smart-HTTP test Build the native fixture server: diff --git a/userland/git/src/changes.rs b/userland/git/src/changes.rs index cfe740f..5c26ca3 100644 --- a/userland/git/src/changes.rs +++ b/userland/git/src/changes.rs @@ -373,6 +373,18 @@ pub fn diff(p: DiffParams) -> Result> { let repo = repo::open(&p.repo)?; let (old, new, work) = match (&p.from, &p.to, p.staged.unwrap_or(false)) { (Some(a), Some(b), false) => (tree(&repo, a)?, tree(&repo, b)?, false), + (Some(a), None, staged) => { + let (index, _) = index(&repo)?; + if staged { + (tree(&repo, a)?, index, false) + } else { + ( + tree(&repo, a)?, + worktree(&repo, &index, p.path.as_deref())?, + true, + ) + } + } (None, None, staged) => { repo::worktree(&repo)?; let (index, conflicts) = index(&repo)?; diff --git a/userland/git/src/cherry_pick.rs b/userland/git/src/cherry_pick.rs new file mode 100644 index 0000000..78098f5 --- /dev/null +++ b/userland/git/src/cherry_pick.rs @@ -0,0 +1,180 @@ +//! Single-commit cherry-pick uses Git's CHERRY_PICK_HEAD and MERGE_MSG for continuation. +use crate::{Result, branches, checkout, local, output, repo::error, staging}; +use gix::bstr::ByteSlice; + +pub(crate) fn merge_trees( + repo: &gix::Repository, + base: gix::ObjectId, + ours: gix::ObjectId, + theirs: gix::ObjectId, + current: &str, + other: &str, +) -> Result<(gix::ObjectId, gix::index::State, Vec)> { + let local = crate::merge::merge_repository(repo)?; + let labels = gix::merge::blob::builtin_driver::text::Labels { + ancestor: None, + current: Some(current.into()), + other: Some(other.into()), + }; + let mut outcome = local + .merge_trees( + base, + ours, + theirs, + labels, + local + .tree_merge_options() + .map_err(|e| error("merge options", e))?, + ) + .map_err(|e| error("merge trees", e))?; + let tree = outcome + .tree + .write() + .map_err(|e| error("merge tree", e))? + .detach(); + let mut index = gix::index::State::from_tree(&tree, &repo.objects, Default::default()) + .map_err(|e| error("merge index", e))?; + outcome.index_changed_after_applying_conflicts( + &mut index, + Default::default(), + gix::merge::tree::apply_index_entries::RemovalMode::Prune, + ); + index.sort_entries(); + let conflicts = index + .entries() + .iter() + .filter(|e| e.stage_raw() != 0) + .map(|e| e.path(&index).to_str_lossy().into_owned()) + .collect::>() + .into_iter() + .collect(); + Ok((tree, index, conflicts)) +} +fn clear(repo: &gix::Repository) -> Result<()> { + for name in ["CHERRY_PICK_HEAD", "MERGE_MSG"] { + let path = repo.git_dir().join(name); + if path.exists() { + std::fs::remove_file(path).map_err(|e| error("cherry-pick cleanup", e))?; + } + } + Ok(()) +} +pub(crate) fn run( + repo: &gix::Repository, + revision: Option, + resume: bool, + abort: bool, + quiet: bool, +) -> Result { + if abort { + if !repo.git_dir().join("CHERRY_PICK_HEAD").exists() { + return Err(error("cherry-pick abort", "no cherry-pick in progress")); + } + let old = std::fs::read_to_string(repo.git_dir().join("ORIG_HEAD")) + .map_err(|e| error("cherry-pick abort", e))?; + local::reset(repo, old.trim(), &[], false, true, true)?; + clear(repo)?; + return Ok(0); + } + let id = if resume { + let id = std::fs::read_to_string(repo.git_dir().join("CHERRY_PICK_HEAD")) + .map_err(|e| error("cherry-pick continue", e))?; + branches::resolve(repo, id.trim())? + } else { + crate::merge::ensure_idle(repo)?; + crate::merge::clean(repo)?; + branches::resolve(repo, &crate::required(revision, "cherry-pick revision")?)? + }; + let old = branches::resolve(repo, "HEAD")?; + let original = repo + .find_commit(id) + .map_err(|e| error("cherry-pick commit", e))?; + let decoded = original + .decode() + .map_err(|e| error("cherry-pick decode", e))?; + let subject = decoded + .message + .to_str_lossy() + .lines() + .next() + .unwrap_or("") + .to_owned(); + let tree = if resume { + staging::tree_from_index(repo, &staging::load(repo)?)? + } else { + let base = match decoded.parents.first() { + Some(id) => local::tree(repo, &id.to_string())?, + None => repo + .write_object(&gix::objs::Tree { entries: vec![] }) + .map_err(|e| error("empty tree", e))? + .detach(), + }; + let ours = local::tree(repo, "HEAD")?; + let (tree, index, conflicts) = merge_trees( + repo, + base, + ours, + decoded.tree(), + "HEAD", + &format!("{} ({subject})", &id.to_string()[..7]), + )?; + checkout::write_tree_to_worktree(repo, Some(ours), tree, false)?; + staging::publish(repo, index)?; + if !conflicts.is_empty() { + std::fs::write(repo.git_dir().join("CHERRY_PICK_HEAD"), format!("{id}\n")) + .map_err(|e| error("cherry-pick state", e))?; + std::fs::write(repo.git_dir().join("ORIG_HEAD"), format!("{old}\n")) + .map_err(|e| error("cherry-pick state", e))?; + std::fs::write(repo.git_dir().join("MERGE_MSG"), decoded.message.as_bytes()) + .map_err(|e| error("cherry-pick state", e))?; + output::conflicts(&conflicts, false); + return Ok(1); + } + tree + }; + let commit = gix::objs::Commit { + tree, + parents: vec![old].into_iter().collect(), + author: decoded + .author() + .map_err(|e| error("pick author", e))? + .to_owned() + .map_err(|e| error("pick author", e))?, + committer: staging::signature(None, None)?, + encoding: decoded.encoding.map(ToOwned::to_owned), + message: decoded.message.to_owned(), + extra_headers: vec![], + }; + let new = repo + .write_object(&commit) + .map_err(|e| error("cherry-pick write", e))? + .detach(); + staging::update_head( + repo, + Some(old), + new, + &staging::signature(None, None)?, + &format!("cherry-pick: {subject}"), + )?; + clear(repo)?; + if !quiet { + let head = crate::head_info(repo)?; + println!( + "[{} {}] {subject}", + head.branch + .unwrap_or_else(|| "detached HEAD".into()) + .trim_start_matches("refs/heads/"), + &new.to_string()[..7] + ); + output::author_date(&repo.find_commit(new).map_err(|e| error("pick output", e))?)?; + output::commit_stat(&crate::changes::diff(crate::changes::DiffParams { + repo: repo.workdir().unwrap().to_string_lossy().into_owned(), + from: Some(old.to_string()), + to: Some(new.to_string()), + staged: None, + path: None, + context: None, + })?); + } + Ok(0) +} diff --git a/userland/git/src/history.rs b/userland/git/src/history.rs new file mode 100644 index 0000000..c42d582 --- /dev/null +++ b/userland/git/src/history.rs @@ -0,0 +1,77 @@ +//! Path-limited history follows a tree-same parent at merges, as Git's default history simplification does. +use crate::{Result, changes, repo::error}; +use std::collections::BTreeSet; + +pub(crate) fn paths( + repo: &gix::Repository, + start: gix::ObjectId, + path: &str, +) -> Result> { + let empty = repo + .write_object(&gix::objs::Tree { entries: vec![] }) + .map_err(|e| error("log empty tree", e))? + .detach(); + let time = |id| -> Result { + Ok(repo + .find_commit(id) + .map_err(|e| error("log commit", e))? + .committer() + .map_err(|e| error("log committer", e))? + .time() + .map_err(|e| error("log time", e))? + .seconds) + }; + let mut todo = vec![(start, time(start)?)]; + let mut visited = BTreeSet::new(); + let mut visible = Vec::new(); + while !todo.is_empty() { + let next = todo + .iter() + .enumerate() + .min_by_key(|(_, (_, time))| std::cmp::Reverse(*time)) + .unwrap() + .0; + let (id, _) = todo.remove(next); + if !visited.insert(id) { + continue; + } + let commit = repo.find_commit(id).map_err(|e| error("log commit", e))?; + let parents = commit + .parent_ids() + .map(|id| id.detach()) + .collect::>(); + let mut same = None; + for parent in &parents { + if diff(repo, *parent, id, path)?.is_empty() { + same = Some(*parent); + break; + } + } + if let Some(parent) = same { + todo.push((parent, time(parent)?)); + } else { + if !parents.is_empty() || !diff(repo, empty, id, path)?.is_empty() { + visible.push(id); + } + for parent in parents { + todo.push((parent, time(parent)?)); + } + } + } + Ok(visible) +} +fn diff( + repo: &gix::Repository, + from: gix::ObjectId, + to: gix::ObjectId, + path: &str, +) -> Result> { + changes::diff(changes::DiffParams { + repo: repo.workdir().unwrap().to_string_lossy().into_owned(), + from: Some(from.to_string()), + to: Some(to.to_string()), + staged: None, + path: Some(path.into()), + context: None, + }) +} diff --git a/userland/git/src/lib.rs b/userland/git/src/lib.rs index 0a0a8dd..64fba5c 100644 --- a/userland/git/src/lib.rs +++ b/userland/git/src/lib.rs @@ -55,18 +55,28 @@ fn identity(name: Option, email: Option) -> Result { .or_else(|| config.string("user.email").map(|s| s.to_string())), }) } +mod cherry_pick; +mod history; +mod local; +mod stash; use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(name = "git", disable_help_subcommand = true)] pub struct Cli { #[arg(short = 'C', global = true)] directory: Option, + #[arg(short = 'c', global = true)] + config: Vec, + #[arg(short = 'q', long, global = true)] + quiet: bool, #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { Show { + #[arg(long)] + stat: bool, revision: Option, }, Clone { @@ -96,6 +106,8 @@ enum Command { Config { #[arg(long)] local: bool, + #[arg(long)] + get: bool, key: String, value: Option, }, @@ -106,13 +118,17 @@ enum Command { git_dir: bool, #[arg(long)] abbrev_ref: bool, - revision: Option, + #[arg(long)] + short: bool, + revisions: Vec, }, Status { #[arg(short, long)] short: bool, - #[arg(long)] - porcelain: bool, + #[arg(long, num_args = 0..=1, default_missing_value = "v1", require_equals = true)] + porcelain: Option, + #[arg(short = 'b', long)] + branch: bool, }, Add { #[arg(short = 'A', long)] @@ -126,7 +142,13 @@ enum Command { }, Commit { #[arg(short = 'm')] - message: String, + message: Option, + #[arg(short = 'F', conflicts_with = "message")] + file: Option, + #[arg(long)] + amend: bool, + #[arg(long)] + no_edit: bool, #[arg(short, long)] all: bool, #[arg(long)] @@ -140,19 +162,42 @@ enum Command { oneline: bool, #[arg(short = 'n', default_value = "50")] limit: usize, + #[arg(long)] + format: Option, + #[arg(long)] + pretty: Option, + #[arg(short = 'p')] + patch: bool, + #[arg(long)] + stat: bool, revision: Option, + #[arg(last = true)] + paths: Vec, }, Diff { #[arg(long, alias = "staged")] cached: bool, #[arg(long)] stat: bool, + #[arg(long)] + name_only: bool, + #[arg(long)] + name_status: bool, + revisions: Vec, + #[arg(last = true)] + paths: Vec, }, Branch { + #[arg(long)] + show_current: bool, + #[arg(short = 'a')] + all: bool, #[arg(short = 'd')] delete: bool, #[arg(short = 'D')] force_delete: bool, + #[arg(short = 'm')] + rename: bool, name: Option, start: Option, }, @@ -164,7 +209,8 @@ enum Command { Checkout { #[arg(short = 'b')] create: bool, - target: String, + target: Option, + paths: Vec, }, Merge { #[arg(long)] @@ -192,6 +238,54 @@ enum Command { #[command(subcommand)] command: Worktree, }, + LsFiles, + Reset { + #[arg(long, conflicts_with_all = ["hard", "mixed"])] + soft: bool, + #[arg(long, conflicts_with = "mixed")] + hard: bool, + #[arg(long)] + mixed: bool, + revision: Option, + paths: Vec, + }, + Restore { + #[arg(long)] + staged: bool, + paths: Vec, + }, + Stash { + #[arg(short = 'm', long)] + message: Option, + command: Option, + entry: Option, + }, + Tag { + #[arg(short = 'a')] + annotated: bool, + #[arg(short = 'm')] + message: Option, + #[arg(short = 'l', long)] + list: bool, + #[arg(short = 'd')] + delete: bool, + name: Option, + revision: Option, + }, + Remote { + #[arg(short = 'v', long)] + verbose: bool, + command: Option, + name: Option, + url: Option, + }, + CherryPick { + #[arg(long = "continue")] + resume: bool, + #[arg(long)] + abort: bool, + revision: Option, + }, } #[derive(Subcommand)] enum Worktree { @@ -215,6 +309,24 @@ fn required(value: Option, what: &str) -> Result { value.ok_or_else(|| error(what, "argument required")) } pub fn run(cli: Cli) -> Result { + let quiet = cli.quiet; + let count = std::env::var("GIT_CONFIG_COUNT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + for (i, value) in cli.config.iter().enumerate() { + let (key, value) = value.split_once('=').unwrap_or((value, "true")); + // WASI has one thread. Supply Git's per-command configuration before discovery. + unsafe { + std::env::set_var(format!("GIT_CONFIG_KEY_{}", count + i), key); + std::env::set_var(format!("GIT_CONFIG_VALUE_{}", count + i), value); + } + } + if !cli.config.is_empty() { + unsafe { + std::env::set_var("GIT_CONFIG_COUNT", (count + cli.config.len()).to_string()); + } + } if let Some(dir) = cli.directory { std::env::set_current_dir(dir).map_err(|e| error("chdir", e))?; } @@ -225,24 +337,39 @@ pub fn run(cli: Cli) -> Result { } = cli.command { let path = path.unwrap_or_else(|| ".".into()); - let repo = if bare { - gix::init_bare(&path) + let existing = std::path::Path::new(&path) + .join(if bare { "HEAD" } else { ".git/HEAD" }) + .exists(); + let repo = if existing { + repo::open(&path) } else { - gix::init(&path) + let repo = if bare { + gix::init_bare(&path) + } else { + gix::init(&path) + } + .map_err(|e| error("init", e))?; + std::fs::write( + repo.git_dir().join("HEAD"), + format!("ref: refs/heads/{initial_branch}\n"), + ) + .map_err(|e| error("init HEAD", e))?; + Ok(repo) + }?; + if !quiet { + println!( + "{} Git repository in {}/", + if existing { + "Reinitialized existing" + } else { + "Initialized empty" + }, + repo.git_dir() + .canonicalize() + .map_err(|e| error("init", e))? + .display() + ); } - .map_err(|e| error("init", e))?; - std::fs::write( - repo.git_dir().join("HEAD"), - format!("ref: refs/heads/{initial_branch}\n"), - ) - .map_err(|e| error("init HEAD", e))?; - println!( - "Initialized empty Git repository in {}/", - repo.git_dir() - .canonicalize() - .map_err(|e| error("init", e))? - .display() - ); return Ok(0); } if let Command::Clone { url, path } = cli.command { @@ -266,6 +393,101 @@ pub fn run(cli: Cli) -> Result { let path = root.to_string_lossy().into_owned(); let repository = repo::open(&path)?; match cli.command { + Command::Reset { + soft, + hard, + revision, + paths, + .. + } => local::reset( + &repository, + revision.as_deref().unwrap_or("HEAD"), + &paths, + soft, + hard, + quiet, + )?, + Command::Restore { staged, paths } => local::restore( + &repository, + if staged { Some("HEAD") } else { None }, + &paths, + staged, + )?, + Command::Stash { + command, + message, + entry, + } => { + return stash::run( + &repository, + command.as_deref().unwrap_or("push"), + message, + entry, + quiet, + ); + } + Command::Tag { + annotated, + message, + delete, + name, + revision, + .. + } => local::tag(&repository, name, revision, annotated, message, delete)?, + Command::Remote { + verbose, + command, + name, + url, + } => match command.as_deref() { + Some("add") | Some("set-url") => { + remotes::set( + &repository, + &required(name, "remote name")?, + &required(url, "remote URL")?, + command.as_deref() == Some("add"), + )?; + } + Some("remove") => remotes::remove(&repository, &required(name, "remote name")?)?, + Some("get-url") => { + let name = required(name, "remote name")?; + let remote = remotes::list(&repository)? + .into_iter() + .find(|r| r.name == name) + .ok_or_else(|| error("remote", "remote does not exist"))?; + println!("{}", remote.url.unwrap_or_default()); + } + None => { + for remote in remotes::list(&repository)? { + if verbose { + let url = remote.url.unwrap_or_default(); + println!( + "{}\t{} (fetch)\n{}\t{} (push)", + remote.name, url, remote.name, url + ); + } else { + println!("{}", remote.name); + } + } + } + _ => { + eprintln!("usage: git remote [add|remove|set-url|get-url]"); + return Ok(129); + } + }, + Command::CherryPick { + resume, + abort, + revision, + } => return cherry_pick::run(&repository, revision, resume, abort, quiet), + Command::LsFiles => { + let index = repository + .index_or_empty() + .map_err(|e| error("ls-files", e))?; + for entry in index.entries() { + println!("{}", entry.path(&index)); + } + } Command::Config { key, value, .. } => { let file = repository.common_dir().join("config"); if let Some(value) = value { @@ -291,7 +513,8 @@ pub fn run(cli: Cli) -> Result { show_toplevel, git_dir, abbrev_ref, - revision, + short, + revisions, } => { if show_toplevel { println!("{}", root.display()); @@ -306,12 +529,13 @@ pub fn run(cli: Cli) -> Result { .trim_start_matches("refs/heads/") ); } else { - println!( - "{}", - repository - .rev_parse_single(required(revision, "revision")?.as_str()) + for revision in revisions { + let id = repository + .rev_parse_single(revision.as_str()) .map_err(|e| error("revision", e))? - ); + .to_string(); + println!("{}", if short { &id[..7] } else { &id }); + } } } Command::Add { all, mut paths } => { @@ -341,12 +565,33 @@ pub fn run(cli: Cli) -> Result { } Command::Commit { message, + file, + amend, + no_edit, all, allow_empty, .. } => { let before = head_info(&repository)?; - if !allow_empty && merge::pending_parent(&repository)?.is_none() { + let message = if let Some(file) = file { + std::fs::read_to_string(file).map_err(|e| error("commit message", e))? + } else if let Some(message) = message { + message + } else if amend && no_edit { + repository + .head_commit() + .map_err(|e| error("amend", e))? + .decode() + .map_err(|e| error("amend message", e))? + .message + .to_str_lossy() + .into_owned() + } else { + eprintln!("usage: git commit -m | -F | --amend --no-edit"); + return Ok(129); + }; + let message = message.trim_end().to_owned(); + if !amend && !allow_empty && merge::pending_parent(&repository)?.is_none() { let status = changes::status(&repository)?; if status.staged.is_empty() && (!all || status.unstaged.is_empty()) @@ -364,10 +609,13 @@ pub fn run(cli: Cli) -> Result { author_name: None, author_email: None, all: Some(all), - amend: None, + amend: Some(amend), allow_empty: Some(allow_empty), }, )?; + if quiet { + return Ok(0); + } println!( "[{}{} {}] {}", before @@ -376,9 +624,31 @@ pub fn run(cli: Cli) -> Result { .trim_start_matches("refs/heads/"), if before.unborn { " (root-commit)" } else { "" }, &result.id[..7], - message + message.lines().next().unwrap_or("") ); - let old = match before.id { + if amend { + output::author_date( + &repository + .find_commit( + gix::ObjectId::from_hex(result.id.as_bytes()) + .map_err(|e| error("amend", e))?, + ) + .map_err(|e| error("amend", e))?, + )?; + } + let old = match if amend { + repository + .find_commit( + gix::ObjectId::from_hex(result.id.as_bytes()) + .map_err(|e| error("amend", e))?, + ) + .map_err(|e| error("amend", e))? + .parent_ids() + .next() + .map(|id| id.to_string()) + } else { + before.id + } { Some(id) => id, None => repository .write_object(&gix::objs::Tree { entries: vec![] }) @@ -394,7 +664,11 @@ pub fn run(cli: Cli) -> Result { context: None, })?); } - Command::Status { short, porcelain } => { + Command::Status { + short, + porcelain, + branch, + } => { let mut status = changes::status(&repository)?; let index = repository .index_or_empty() @@ -414,10 +688,30 @@ pub fn run(cli: Cli) -> Result { untracked.insert(collapsed.to_owned()); } status.untracked = untracked.into_iter().collect(); - if !short && !porcelain { + if !short && porcelain.is_none() { output::long_status(&status); return Ok(0); } + if branch { + println!( + "## {}", + if status.head.detached { + "HEAD (no branch)".into() + } else { + let name = status + .head + .branch + .as_deref() + .unwrap_or("HEAD") + .trim_start_matches("refs/heads/"); + if status.head.unborn { + format!("No commits yet on {name}") + } else { + name.to_owned() + } + } + ); + } let mut rows = std::collections::BTreeMap::new(); for c in status.staged { rows.entry(c.path).or_insert([' '; 2])[0] = code(&c.status); @@ -431,20 +725,45 @@ pub fn run(cli: Cli) -> Result { for p in status.conflicted { rows.insert(p, ['U', 'U']); } - for (p, c) in rows { - println!("{}{} {p}", c[0], c[1]); + for untracked in [false, true] { + for (p, c) in &rows { + if (c == &['?', '?']) == untracked { + println!("{}{} {p}", c[0], c[1]); + } + } } } - Command::Diff { cached, stat } => { + Command::Diff { + cached, + stat, + name_only, + name_status, + revisions, + paths, + } => { + let (from, to) = if revisions.len() == 1 && revisions[0].contains("..") { + let (a, b) = revisions[0].split_once("..").unwrap(); + (Some(a.into()), Some(b.into())) + } else { + (revisions.first().cloned(), revisions.get(1).cloned()) + }; let diffs = changes::diff(changes::DiffParams { repo: path, - from: None, - to: None, + from, + to, staged: Some(cached), - path: None, + path: paths.first().cloned(), context: None, })?; - if stat { + if name_only || name_status { + for diff in diffs { + if name_status { + println!("{}\t{}", code(&diff.status), diff.path); + } else { + println!("{}", diff.path); + } + } + } else if stat { output::stat(&diffs, false); } else { for diff in diffs { @@ -455,7 +774,12 @@ pub fn run(cli: Cli) -> Result { Command::Log { oneline, limit, + format, + pretty, + patch, + stat, revision, + paths, } => { let commit = repository .rev_parse_single(revision.as_deref().unwrap_or("HEAD")) @@ -464,24 +788,102 @@ pub fn run(cli: Cli) -> Result { .map_err(|e| error("log", e))? .peel_to_commit() .map_err(|e| error("log", e))?; - let mut first = true; - for info in commit - .ancestors() - .use_commit_graph(false) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - Default::default(), - )) - .all() - .map_err(|e| error("log", e))? - .take(limit) - { - let c = info + let ids = if let Some(path) = paths.first() { + history::paths(&repository, commit.id, path)? + } else { + commit + .ancestors() + .use_commit_graph(false) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + Default::default(), + )) + .all() .map_err(|e| error("log", e))? - .object() - .map_err(|e| error("log", e))?; + .take(limit) + .map(|info| info.map(|info| info.id).map_err(|e| error("log", e))) + .collect::>>()? + }; + let mut first = true; + for id in ids.into_iter().take(limit) { + let c = repository.find_commit(id).map_err(|e| error("log", e))?; let decoded = c.decode().map_err(|e| error("log", e))?; let message = decoded.message.to_str_lossy(); - if oneline { + let diffs = if patch || stat || !paths.is_empty() { + let old = match decoded.parents.first() { + Some(id) => id.to_string(), + None => repository + .write_object(&gix::objs::Tree { entries: vec![] }) + .map_err(|e| error("log tree", e))? + .to_string(), + }; + changes::diff(changes::DiffParams { + repo: path.clone(), + from: Some(old), + to: Some(c.id.to_string()), + staged: None, + path: paths.first().cloned(), + context: None, + })? + } else { + Vec::new() + }; + if let Some(template) = format.as_deref().or(pretty + .as_deref() + .map(|s| s.strip_prefix("format:").unwrap_or(s))) + { + let author = c.author().map_err(|e| error("log author", e))?; + let date = author + .time() + .map_err(|e| error("log date", e))? + .format_or_unix(gix::date::time::format::DEFAULT); + let mut labels = Vec::new(); + for reference in repository + .references() + .map_err(|e| error("log refs", e))? + .all() + .map_err(|e| error("log refs", e))? + { + let reference = reference.map_err(|e| error("log refs", e))?; + let name = reference.name().as_bstr().to_str_lossy(); + if repository + .rev_parse_single(name.as_ref()) + .map(|id| id.to_string() == c.id.to_string()) + .unwrap_or(false) + { + let short = reference.name().shorten().to_string(); + labels.push( + if head_info(&repository)?.branch.as_deref() == Some(name.as_ref()) + { + format!("HEAD -> {short}") + } else { + short + }, + ); + } + } + let decorations = if labels.is_empty() { + String::new() + } else { + format!(" ({})", labels.join(", ")) + }; + let text = template + .replace("%H", &c.id.to_string()) + .replace("%h", &c.id.to_string()[..7]) + .replace("%s", message.lines().next().unwrap_or("")) + .replace("%an", &author.name.to_string()) + .replace("%ae", &author.email.to_string()) + .replace("%ad", &date) + .replace("%d", &decorations); + if pretty.is_some() { + if !first { + println!(); + } + print!("{text}"); + } else { + println!("{text}"); + } + first = false; + } else if oneline { println!( "{} {}", &c.id.to_string()[..7], @@ -494,24 +896,61 @@ pub fn run(cli: Cli) -> Result { output::commit(&c)?; first = false; } + if patch || stat { + println!(); + if stat { + output::stat(&diffs, false); + } + if patch { + for diff in diffs { + print!("{}", diff.patch); + } + } + } } } Command::Branch { delete, force_delete, + show_current, + all, + rename, name, start, } => { + if rename { + let new = required(start.clone().or(name.clone()), "new branch name")?; + local::rename_branch( + &repository, + if start.is_some() { + name.as_deref() + } else { + None + }, + &new, + )?; + return Ok(0); + } + if show_current { + if let Some(branch) = head_info(&repository)?.branch { + println!("{}", branch.trim_start_matches("refs/heads/")); + } + return Ok(0); + } if let Some(name) = name { if delete || force_delete { + let id = branches::resolve(&repository, &name)?.to_string(); branches::delete( &repository, branches::BranchDeleteParams { repo: path, - name, + name: name.clone(), force: Some(force_delete), }, )?; + if !quiet { + println!("Deleted branch {name} (was {}).", &id[..7]); + } } else { branches::create( &repository, @@ -528,10 +967,16 @@ pub fn run(cli: Cli) -> Result { for branch in repository .references() .map_err(|e| error("branch", e))? - .local_branches() + .all() .map_err(|e| error("branch", e))? { let branch = branch.map_err(|e| error("branch", e))?; + let name = branch.name().as_bstr().to_str_lossy(); + if !name.starts_with("refs/heads/") + && !(all && name.starts_with("refs/remotes/")) + { + continue; + } println!( "{} {}", if head.branch.as_deref() @@ -541,12 +986,48 @@ pub fn run(cli: Cli) -> Result { } else { " " }, - branch.name().shorten() + if name.starts_with("refs/remotes/") { + format!("remotes/{}", name.trim_start_matches("refs/remotes/")) + } else { + branch.name().shorten().to_string() + } ); } } } - Command::Switch { create, target } | Command::Checkout { create, target } => { + Command::Checkout { + create, + target, + mut paths, + } => { + if !create + && (!paths.is_empty() + || target.as_ref().is_some_and(|t| { + root.join(t).is_file() + && repository + .try_find_reference(format!("refs/heads/{t}").as_str()) + .ok() + .flatten() + .is_none() + })) + { + if let Some(target) = target { + paths.insert(0, target); + } + local::restore(&repository, None, &paths, false)?; + } else { + branches::checkout( + &repository, + branches::CheckoutParams { + repo: path, + target: required(target, "checkout target")?, + create: Some(create), + force: None, + }, + )?; + } + } + Command::Switch { create, target } => { branches::checkout( &repository, branches::CheckoutParams { @@ -588,10 +1069,13 @@ pub fn run(cli: Cli) -> Result { }, )? }; - if r.already_up_to_date { + if r.already_up_to_date && !quiet { println!("Already up to date."); } output::conflicts(&r.conflicts, true); + if quiet { + return Ok(if r.conflicts.is_empty() { 0 } else { 1 }); + } if !r.conflicts.is_empty() { return Ok(1); } @@ -637,6 +1121,10 @@ pub fn run(cli: Cli) -> Result { }, )? }; + if quiet { + output::conflicts(&r.conflicts, false); + return Ok(if r.conflicts.is_empty() { 0 } else { 1 }); + } if let Some(id) = &r.resolved { let commit = repository .find_commit( @@ -766,7 +1254,7 @@ pub fn run(cli: Cli) -> Result { )?; } }, - Command::Show { revision } => { + Command::Show { revision, stat } => { let object = repository .rev_parse_single(revision.as_deref().unwrap_or("HEAD")) .map_err(|e| error("show", e))? @@ -779,8 +1267,38 @@ pub fn run(cli: Cli) -> Result { .map_err(|e| error("show", e))?; } else { let commit = object.peel_to_commit().map_err(|e| error("show", e))?; + let merge_commit = commit.parent_ids().count() > 1; + if merge_commit && !stat { + let mut common: Option> = None; + for parent in commit.parent_ids() { + let names = changes::diff(changes::DiffParams { + repo: path.clone(), + from: Some(parent.to_string()), + to: Some(commit.id.to_string()), + staged: None, + path: None, + context: None, + })? + .into_iter() + .map(|d| d.path) + .collect::>(); + common = Some(match common { + None => names, + Some(paths) => paths.intersection(&names).cloned().collect(), + }); + } + if common.is_some_and(|paths| !paths.is_empty()) { + eprintln!( + "usage: git show: combined merge diffs are not supported (see README.md)" + ); + return Ok(129); + } + } output::commit(&commit)?; println!(); + if merge_commit && !stat { + return Ok(0); + } let old = match commit.parent_ids().next() { Some(id) => id.to_string(), None => repository @@ -788,15 +1306,20 @@ pub fn run(cli: Cli) -> Result { .map_err(|e| error("show", e))? .to_string(), }; - for diff in changes::diff(changes::DiffParams { + let diffs = changes::diff(changes::DiffParams { repo: path, from: Some(old), to: Some(commit.id.to_string()), staged: None, path: None, context: None, - })? { - print!("{}", diff.patch); + })?; + if stat { + output::stat(&diffs, false); + } else { + for diff in diffs { + print!("{}", diff.patch); + } } } } diff --git a/userland/git/src/local.rs b/userland/git/src/local.rs new file mode 100644 index 0000000..7cb205d --- /dev/null +++ b/userland/git/src/local.rs @@ -0,0 +1,283 @@ +//! Local reset, path restoration, tags, remotes, and branch renames use ordinary Git metadata. +use crate::{Result, branches, changes, checkout, head_info, remotes, repo::error, staging}; +use gix::bstr::ByteSlice; +use gix::index::State; +#[cfg(unix)] +use gix::index::entry::Mode; +use gix::refs::{ + Target, + transaction::{Change, PreviousValue, RefEdit}, +}; + +pub(crate) fn tree(repo: &gix::Repository, revision: &str) -> Result { + Ok(repo + .rev_parse_single(revision) + .map_err(|e| error("revision", e))? + .object() + .map_err(|e| error("revision", e))? + .peel_to_tree() + .map_err(|e| error("tree", e))? + .id) +} +fn selected(path: &str, paths: &[String]) -> bool { + paths + .iter() + .any(|p| p == "." || path == p || path.strip_prefix(p).is_some_and(|s| s.starts_with('/'))) +} +pub(crate) fn restore( + repo: &gix::Repository, + revision: Option<&str>, + paths: &[String], + staged: bool, +) -> Result<()> { + let source = match revision { + Some(revision) => { + State::from_tree(&tree(repo, revision)?, &repo.objects, Default::default()) + .map_err(|e| error("restore tree", e))? + } + None => staging::load(repo)?, + }; + let mut state = staging::load(repo)?; + if staged { + state.remove_entries(|_, p, _| selected(p.to_str_lossy().as_ref(), paths)); + for e in source + .entries() + .iter() + .filter(|e| selected(e.path(&source).to_str_lossy().as_ref(), paths)) + { + state.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, e.path(&source)); + } + staging::publish(repo, state)?; + } else { + let root = crate::repo::worktree(repo)?; + for path in paths { + if path != "." { + crate::repo::validate_path(path)?; + } + } + for e in source + .entries() + .iter() + .filter(|e| selected(e.path(&source).to_str_lossy().as_ref(), paths)) + { + if e.stage_raw() != 0 { + return Err(error("restore", "unmerged path")); + } + staging::safe(root, e.path(&source).to_str_lossy().as_ref())?; + let path = root.join(e.path(&source).to_str_lossy().as_ref()); + let bytes = repo + .find_blob(e.id) + .map_err(|e| error("restore blob", e))? + .data + .to_vec(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| error("restore directory", e))?; + } + std::fs::write(&path, bytes).map_err(|e| error("restore file", e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &path, + std::fs::Permissions::from_mode(if e.mode == Mode::FILE_EXECUTABLE { + 0o755 + } else { + 0o644 + }), + ) + .map_err(|e| error("restore mode", e))?; + } + } + } + Ok(()) +} +pub(crate) fn reset( + repo: &gix::Repository, + revision: &str, + paths: &[String], + soft: bool, + hard: bool, + quiet: bool, +) -> Result<()> { + if !paths.is_empty() { + restore(repo, Some(revision), paths, true)?; + } else { + let id = branches::resolve(repo, revision)?; + let old = head_info(repo)?.id; + if hard { + checkout::write_tree_to_worktree(repo, None, tree(repo, revision)?, true)?; + } else if !soft { + let state = State::from_tree(&tree(repo, revision)?, &repo.objects, Default::default()) + .map_err(|e| error("reset index", e))?; + staging::publish(repo, state)?; + } + if let Some(old) = old { + std::fs::write(repo.git_dir().join("ORIG_HEAD"), format!("{old}\n")) + .map_err(|e| error("reset ORIG_HEAD", e))?; + staging::update_head( + repo, + Some(gix::ObjectId::from_hex(old.as_bytes()).map_err(|e| error("reset HEAD", e))?), + id, + &staging::signature(None, None)?, + "reset", + )?; + } + if hard && !quiet { + let commit = repo.find_commit(id).map_err(|e| error("reset commit", e))?; + println!( + "HEAD is now at {} {}", + &id.to_string()[..7], + commit + .decode() + .map_err(|e| error("reset message", e))? + .message + .to_str_lossy() + .lines() + .next() + .unwrap_or("") + ); + } + } + if !soft && !hard && !quiet { + let status = changes::status(&crate::repo::open( + &repo.workdir().unwrap().to_string_lossy(), + )?)?; + if !status.unstaged.is_empty() { + println!("Unstaged changes after reset:"); + for c in status.unstaged { + println!("{}\t{}", crate::code(&c.status), c.path); + } + } + } + Ok(()) +} +pub(crate) fn rename_branch(repo: &gix::Repository, old: Option<&str>, new: &str) -> Result<()> { + let head = head_info(repo)?; + let old = branches::branch_name( + old.unwrap_or( + head.branch + .as_deref() + .unwrap_or("HEAD") + .trim_start_matches("refs/heads/"), + ), + )?; + let new = branches::branch_name(new)?; + crate::worktrees::ensure_available(repo, &old)?; + let mut edits = vec![ + RefEdit { + name: new + .as_str() + .try_into() + .map_err(|e| error("branch name", e))?, + deref: false, + change: Change::Update { + expected: PreviousValue::MustNotExist, + new: Target::Object(branches::resolve(repo, &old)?), + log: Default::default(), + }, + }, + RefEdit { + name: old + .as_str() + .try_into() + .map_err(|e| error("branch name", e))?, + deref: false, + change: Change::Delete { + expected: PreviousValue::MustExist, + log: gix::refs::transaction::RefLog::AndReference, + }, + }, + ]; + if head.branch.as_deref() == Some(old.as_str()) { + edits.push(RefEdit { + name: "HEAD".try_into().unwrap(), + deref: false, + change: Change::Update { + expected: PreviousValue::Any, + new: Target::Symbolic(new.as_str().try_into().unwrap()), + log: Default::default(), + }, + }); + } + branches::edit_refs(repo, edits)?; + let mut config = remotes::load(repo)?; + let old_short = old.trim_start_matches("refs/heads/"); + if config.section("branch", old_short).is_ok() { + config + .rename_section( + "branch", + old_short, + "branch", + new.trim_start_matches("refs/heads/"), + ) + .map_err(|e| error("branch config", e))?; + remotes::save(repo, &config)?; + } + Ok(()) +} +pub(crate) fn tag( + repo: &gix::Repository, + name: Option, + revision: Option, + annotated: bool, + message: Option, + delete: bool, +) -> Result<()> { + let Some(name) = name else { + for reference in repo + .references() + .map_err(|e| error("tags", e))? + .all() + .map_err(|e| error("tags", e))? + { + let reference = reference.map_err(|e| error("tag", e))?; + if reference.name().as_bstr().starts_with(b"refs/tags/") { + println!("{}", reference.name().shorten()); + } + } + return Ok(()); + }; + let full = format!("refs/tags/{name}"); + if delete { + let reference = repo + .find_reference(full.as_str()) + .map_err(|e| error("tag", e))?; + let id = reference + .target() + .try_id() + .ok_or_else(|| error("tag", "symbolic tag"))? + .to_string(); + reference.delete().map_err(|e| error("tag delete", e))?; + println!("Deleted tag '{name}' (was {})", &id[..7]); + } else { + let id = branches::resolve(repo, revision.as_deref().unwrap_or("HEAD"))?; + let id = if annotated || message.is_some() { + let object = gix::objs::Tag { + target: id, + target_kind: gix::objs::Kind::Commit, + name: name.clone().into(), + tagger: Some(staging::signature(None, None)?), + message: format!("{}\n", message.unwrap_or_default().trim_end()).into(), + signature: None, + }; + repo.write_object(&object) + .map_err(|e| error("tag object", e))? + .detach() + } else { + id + }; + branches::edit_refs( + repo, + vec![RefEdit { + name: full.as_str().try_into().map_err(|e| error("tag name", e))?, + deref: false, + change: Change::Update { + expected: PreviousValue::MustNotExist, + new: Target::Object(id), + log: Default::default(), + }, + }], + )?; + } + Ok(()) +} diff --git a/userland/git/src/output.rs b/userland/git/src/output.rs index 5586bd7..5e892c3 100644 --- a/userland/git/src/output.rs +++ b/userland/git/src/output.rs @@ -36,6 +36,19 @@ pub(crate) fn commit(commit: &gix::Commit<'_>) -> crate::Result<()> { ); Ok(()) } +pub(crate) fn author_date(commit: &gix::Commit<'_>) -> crate::Result<()> { + let author = commit + .author() + .map_err(|e| crate::repo::error("author", e))?; + println!( + " Date: {}", + author + .time() + .map_err(|e| crate::repo::error("date", e))? + .format_or_unix(gix::date::time::format::DEFAULT) + ); + Ok(()) +} pub(crate) fn long_status(s: &Status) { let branch = s .head @@ -43,7 +56,14 @@ pub(crate) fn long_status(s: &Status) { .as_deref() .unwrap_or("HEAD") .trim_start_matches("refs/heads/"); - println!("On branch {branch}"); + if s.head.detached { + println!( + "HEAD detached at {}", + &s.head.id.as_deref().unwrap_or("HEAD")[..7] + ); + } else { + println!("On branch {branch}"); + } if s.head.unborn { println!("\nNo commits yet\n"); } diff --git a/userland/git/src/remotes.rs b/userland/git/src/remotes.rs index ac32790..fe104c0 100644 --- a/userland/git/src/remotes.rs +++ b/userland/git/src/remotes.rs @@ -116,7 +116,7 @@ pub fn set(repo: &gix::Repository, name: &str, url: &str, add: bool) -> Result = config .sections_and_ids_by_name("remote") @@ -131,17 +131,19 @@ pub fn set(repo: &gix::Repository, name: &str, url: &str, add: bool) -> Result Result> { } } // Do not traverse symlink parents even when an explicit path points outside via a link. -fn safe(root: &Path, path: &str) -> Result<()> { +pub(crate) fn safe(root: &Path, path: &str) -> Result<()> { validate(path)?; for parent in Path::new(path) .ancestors() @@ -129,7 +129,7 @@ fn safe(root: &Path, path: &str) -> Result<()> { } Ok(()) } -fn load(repo: &gix::Repository) -> Result { +pub(crate) fn load(repo: &gix::Repository) -> Result { repo::worktree(repo)?; let index = repo.index_or_empty().map_err(|e| error("stage index", e))?; let mut state = State::new(repo.object_hash()); @@ -324,6 +324,16 @@ fn stage(repo: &gix::Repository, state: State, selected: BTreeSet) -> Re state.sort_entries(); Ok(state) } +pub(crate) fn tracked_tree(repo: &gix::Repository) -> Result { + let state = load(repo)?; + let selected = state + .entries() + .iter() + .filter(|e| e.mode != Mode::COMMIT) + .map(|e| e.path(&state).to_str_lossy().into_owned()) + .collect(); + tree_from_index(repo, &stage(repo, state, selected)?) +} pub(crate) fn add(repo: &gix::Repository, p: AddParams) -> Result { if !p.all.unwrap_or(false) { validate_paths(&p.paths)?; diff --git a/userland/git/src/stash.rs b/userland/git/src/stash.rs new file mode 100644 index 0000000..8ea2e6d --- /dev/null +++ b/userland/git/src/stash.rs @@ -0,0 +1,227 @@ +//! Stashes are standard two-parent Git commits with a refs/stash reflog. +use crate::{ + Result, branches, changes, checkout, cherry_pick, local, output, repo::error, staging, +}; +use gix::{ + bstr::ByteSlice, + refs::{ + Target, + transaction::{Change, LogChange, PreviousValue, RefEdit}, + }, +}; +fn log_path(repo: &gix::Repository) -> std::path::PathBuf { + repo.common_dir().join("logs/refs/stash") +} +fn logs(repo: &gix::Repository) -> Result> { + match std::fs::read_to_string(log_path(repo)) { + Ok(text) => Ok(text.lines().map(str::to_owned).collect()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]), + Err(e) => Err(error("stash log", e)), + } +} +fn commit( + repo: &gix::Repository, + tree: gix::ObjectId, + parents: Vec, + message: &str, +) -> Result { + let sig = staging::signature(None, None)?; + repo.write_object(&gix::objs::Commit { + tree, + parents: parents.into_iter().collect(), + author: sig.clone(), + committer: sig, + encoding: None, + message: message.into(), + extra_headers: vec![], + }) + .map(|id| id.detach()) + .map_err(|e| error("stash commit", e)) +} +fn update(repo: &gix::Repository, id: gix::ObjectId, message: &str) -> Result<()> { + branches::edit_refs( + repo, + vec![RefEdit { + name: "refs/stash".try_into().unwrap(), + deref: false, + change: Change::Update { + expected: PreviousValue::Any, + new: Target::Object(id), + log: LogChange { + message: message.into(), + force_create_reflog: true, + ..Default::default() + }, + }, + }], + )?; + Ok(()) +} +fn drop_entry(repo: &gix::Repository, n: usize, id: gix::ObjectId, quiet: bool) -> Result<()> { + let mut rows = logs(repo)?; + let offset = rows + .len() + .checked_sub(n + 1) + .ok_or_else(|| error("stash", "no such stash"))?; + rows.remove(offset); + if rows.is_empty() { + repo.find_reference("refs/stash") + .map_err(|e| error("stash ref", e))? + .delete() + .map_err(|e| error("stash drop", e))?; + } else { + let tip = rows.last().unwrap().split_whitespace().nth(1).unwrap(); + update( + repo, + gix::ObjectId::from_hex(tip.as_bytes()).map_err(|e| error("stash id", e))?, + "stash drop", + )?; + std::fs::write(log_path(repo), format!("{}\n", rows.join("\n"))) + .map_err(|e| error("stash log", e))?; + } + if !quiet { + println!("Dropped refs/stash@{{{n}}} ({id})"); + } + Ok(()) +} +pub(crate) fn run( + repo: &gix::Repository, + command: &str, + message: Option, + entry: Option, + quiet: bool, +) -> Result { + if !matches!(command, "push" | "list" | "apply" | "pop" | "drop") { + return Err(error("stash", "unsupported subcommand")); + } + if command == "push" { + crate::merge::ensure_idle(repo)?; + let status = changes::status(repo)?; + if status.staged.is_empty() && status.unstaged.is_empty() { + if !quiet { + println!("No local changes to save"); + } + return Ok(0); + } + let head = branches::resolve(repo, "HEAD")?; + let original = repo.find_commit(head).map_err(|e| error("stash HEAD", e))?; + let subject = original + .decode() + .map_err(|e| error("stash message", e))? + .message + .to_str_lossy() + .lines() + .next() + .unwrap_or("") + .to_owned(); + let branch = crate::head_info(repo)? + .branch + .unwrap_or_else(|| "(no branch)".into()) + .trim_start_matches("refs/heads/") + .to_owned(); + let description = format!("{branch}: {} {subject}", &head.to_string()[..7]); + let index = repo.index_or_empty().map_err(|e| error("stash index", e))?; + let index_tree = staging::tree_from_index(repo, &index)?; + let index_commit = commit( + repo, + index_tree, + vec![head], + &format!("index on {description}\n"), + )?; + let work_tree = staging::tracked_tree(repo)?; + let message = message + .map(|m| format!("On {branch}: {m}")) + .unwrap_or_else(|| format!("WIP on {description}")); + let stash = commit(repo, work_tree, vec![head, index_commit], &message)?; + update(repo, stash, &message)?; + checkout::write_tree_to_worktree(repo, None, local::tree(repo, "HEAD")?, true)?; + if !quiet { + println!("Saved working directory and index state {message}"); + } + return Ok(0); + } + let rows = logs(repo)?; + if command == "list" { + for (n, row) in rows.iter().rev().enumerate() { + println!( + "stash@{{{n}}}: {}", + row.split_once('\t').map(|(_, m)| m).unwrap_or("") + ); + } + return Ok(0); + } + let n = entry + .as_deref() + .map(|s| { + s.strip_prefix("stash@{") + .and_then(|s| s.strip_suffix('}')) + .unwrap_or(s) + .parse::() + .map_err(|e| error("stash entry", e)) + }) + .transpose()? + .unwrap_or(0); + let Some(row) = rows.iter().rev().nth(n) else { + eprintln!("No stash entries found."); + return Ok(1); + }; + let id = gix::ObjectId::from_hex(row.split_whitespace().nth(1).unwrap().as_bytes()) + .map_err(|e| error("stash id", e))?; + if command == "drop" { + drop_entry(repo, n, id, quiet)?; + return Ok(0); + } + if !matches!(command, "apply" | "pop") { + eprintln!("usage: git stash [push|list|pop|apply|drop]"); + return Ok(129); + } + let stash = repo.find_commit(id).map_err(|e| error("stash commit", e))?; + let decoded = stash.decode().map_err(|e| error("stash decode", e))?; + let base = local::tree(repo, &decoded.parents[0].to_string())?; + let old_index = repo.index_or_empty().map_err(|e| error("stash index", e))?; + let ours = staging::tracked_tree(repo)?; + let (merged, mut index, conflicts) = cherry_pick::merge_trees( + repo, + base, + ours, + decoded.tree(), + "Updated upstream", + "Stashed changes", + )?; + checkout::write_tree_to_worktree(repo, Some(ours), merged, true)?; + if conflicts.is_empty() { + // Without --index, tracked modifications remain unstaged. Stashed additions stay staged. + let base_index = gix::index::State::from_tree(&base, &repo.objects, Default::default()) + .map_err(|e| error("stash base", e))?; + let added = index + .entries() + .iter() + .filter(|e| { + !base_index + .entries() + .iter() + .any(|b| b.path(&base_index) == e.path(&index)) + }) + .map(|e| e.path(&index).to_str_lossy().into_owned()) + .collect::>(); + index.remove_entries(|_, path, _| !added.contains(path.to_str_lossy().as_ref())); + for e in old_index.entries() { + if !added.contains(e.path(&old_index).to_str_lossy().as_ref()) { + index.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, e.path(&old_index)); + } + } + } + staging::publish(repo, index)?; + let repo = crate::repo::open(&repo.workdir().unwrap().to_string_lossy())?; + output::conflicts(&conflicts, false); + if !quiet { + output::long_status(&changes::status(&repo)?); + } + if !conflicts.is_empty() { + return Ok(1); + } + if command == "pop" { + drop_entry(&repo, n, id, quiet)?; + } + Ok(0) +} diff --git a/userland/git/test.nu b/userland/git/test.nu index bf35d4e..5b57829 100644 --- a/userland/git/test.nu +++ b/userland/git/test.nu @@ -16,15 +16,29 @@ def main [component: path] { with-env $envs { for side in [native wasi] { mkdir ($root | path join $side repo) } let steps = [ - {args: [init -b main], compare: false} + {args: [init -q -b main], compare: true} {args: [config user.name Agent], compare: true} {args: [config user.email agent@example.test], compare: true} + {args: [-c user.name=Override config --get user.name], compare: true} + {args: [config --get user.name], compare: true} {write: 'base\n', file: 'file.txt'} {args: [add .], compare: true} {args: [commit -m initial], compare: true} {args: [status --short], compare: true} + {args: [status -sb], compare: true} + {args: [status --porcelain=v1], compare: true} {args: [status], compare: true} + {args: [ls-files], compare: true} + {args: [branch --show-current], compare: true} + {args: [branch -a], compare: true} {args: [log -n 1], compare: true} + {args: [log -n 1 '--format=%H %h %s %an %ae %ad %d'], compare: true} + {args: [log -n 1 '--pretty=format:%H %h %s %an %ae %ad %d'], compare: true} + {args: [log -n 1 -p], compare: true} + {args: [log -n 1 --stat], compare: true} + {args: [log --oneline -- file.txt], compare: true} + {args: [show --stat], compare: true} + {args: [rev-parse --short HEAD], compare: true} {args: [show HEAD], compare: true} {args: [show HEAD:file.txt], compare: true} {args: [branch topic], compare: true} @@ -32,6 +46,18 @@ def main [component: path] { {write: 'topic\n', file: 'topic.txt'} {args: [add .], compare: true} {args: [commit -m topic], compare: true} + {args: [rev-parse HEAD HEAD~1], compare: true} + {args: [diff HEAD~1], compare: true} + {args: [diff HEAD~1..HEAD], compare: true} + {args: [diff --name-only HEAD~1..HEAD], compare: true} + {args: [diff --name-status HEAD~1..HEAD], compare: true} + {args: [diff HEAD~1 -- topic.txt], compare: true} + {args: [branch disposable], compare: true} + {args: [branch -d disposable], compare: true} + {args: [branch disposable], compare: true} + {args: [branch -D disposable], compare: true} + {args: [branch disposable], compare: true} + {args: [branch -q -d disposable], compare: true} {args: [switch main], compare: true} {args: [merge topic], compare: true} {write: 'changed\n', file: 'file.txt'} @@ -48,6 +74,8 @@ def main [component: path] { {args: [merge topic], compare: true, exit: 1} {args: [status --short], compare: true} {args: [merge --abort], compare: true} + {args: [merge -q topic], compare: true, exit: 1} + {args: [merge --abort], compare: true} {args: [worktree add -b linked ../linked], compare: true} {args: [worktree list], compare: false} {args: [worktree remove ../linked], compare: true} @@ -56,6 +84,7 @@ def main [component: path] { {write: 'resolved\n', file: 'file.txt'} {args: [add file.txt], compare: true} {args: [rebase --continue], compare: true} + {args: [rebase -q main], compare: true} {args: [log --oneline], compare: true} {args: [switch main], compare: true} {write: 'left\n', file: 'left.txt'} @@ -67,11 +96,137 @@ def main [component: path] { {args: [commit -m right], compare: true} {args: [switch main], compare: true} {args: [merge topic], compare: true} + {args: [show HEAD], compare: true} {args: [branch], compare: true} {args: [rm right.txt], compare: true} {args: [diff --cached], compare: true} {args: [status --porcelain], compare: true} {args: [commit -m removed], compare: true} + {args: [checkout HEAD~1], compare: true} + {args: [status], compare: true} + {args: [status -sb], compare: true} + {args: [switch main], compare: true} + {args: [checkout -q -b agent-checkout], compare: true} + {args: [switch -q main], compare: true} + {args: [switch -q -c agent-switch], compare: true} + {write: 'agent\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [commit -q -m agent], compare: true} + {args: [switch main], compare: true} + {args: [merge --no-ff -q agent-switch], compare: true} + {args: [show --stat], compare: true} + {args: [commit --quiet --allow-empty -m quiet], compare: true} + {args: [init], compare: true, normalize_path: true, state: true} + {args: [init -q], compare: true, state: true} + {args: [log -n 1 --oneline -- file.txt], compare: true} + {args: [log -n 2 --oneline -- file.txt], compare: true} + {args: [config branch.main.description agent-branch], compare: true} + {args: [branch -m rework], compare: true, state: true} + {args: [config --get branch.rework.description], compare: true} + {args: [branch -m agent-checkout renamed], compare: true, state: true} + {args: [branch], compare: true} + {args: [remote add example https://example.test/repo.git], compare: true, state: true} + {args: [remote -v], compare: true} + {args: [remote set-url example https://example.test/new.git], compare: true, state: true} + {args: [remote get-url example], compare: true} + {args: [remote remove example], compare: true, state: true} + {args: [remote -v], compare: true} + {args: [tag lightweight], compare: true, state: true} + {args: [tag -a annotated -m annotation], compare: true, state: true} + {args: [tag], compare: true} + {args: [tag --list], compare: true} + {args: [tag -d annotated], compare: true, state: true} + {args: [tag -d lightweight], compare: true, state: true} + {write: 'amended\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [commit --amend -m amended], compare: true, state: true} + {args: [commit --amend --no-edit], compare: true, state: true} + {write: 'file message\n\nbody\n', file: 'message.txt'} + {write: 'from-file\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [commit -F message.txt], compare: true, state: true} + {write: 'dirty\n', file: 'agent.txt'} + {args: [checkout agent.txt], compare: true, state: true} + {write: 'dirty\n', file: 'agent.txt'} + {args: [checkout -- agent.txt], compare: true, state: true} + {write: 'dirty\n', file: 'agent.txt'} + {args: [restore agent.txt], compare: true, state: true} + {write: 'staged\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [restore --staged agent.txt], compare: true, state: true} + {args: [status --short], compare: true} + {args: [restore agent.txt], compare: true, state: true} + {write: 'staged\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [reset HEAD agent.txt], compare: true, state: true} + {args: [restore agent.txt], compare: true, state: true} + {args: [branch saved], compare: true} + {args: [reset --soft HEAD~1], compare: true, state: true} + {args: [status --short], compare: true} + {args: [log --oneline -n 2], compare: true} + {args: [reset --hard saved], compare: true, state: true} + {args: [reset HEAD~1], compare: true, state: true} + {args: [status --short], compare: true} + {args: [reset --hard saved], compare: true, state: true} + {args: [reset --hard HEAD~1], compare: true, state: true} + {args: [reset --hard saved], compare: true, state: true} + {write: 'stash one\n', file: 'agent.txt'} + {args: [stash push], compare: true, state: true} + {args: [stash list], compare: true} + {args: [stash bogus], compare: true, exit: 128, state: true} + {args: [stash apply], compare: true, state: true} + {args: [reset --hard], compare: true, state: true} + {args: [stash drop], compare: true, state: true} + {write: 'stash two\n', file: 'agent.txt'} + {args: [stash -m custom], compare: true, state: true} + {args: [stash list], compare: true} + {write: 'stash three\n', file: 'agent.txt'} + {args: [stash], compare: true, state: true} + {args: [stash list], compare: true} + {args: [stash drop], compare: true, state: true} + {args: [stash pop], compare: true, state: true} + {args: [status --short], compare: true} + {args: [reset --hard], compare: true, state: true} + {args: [stash push], compare: true, state: true} + {write: 'added stash\n', file: 'stash-added.txt'} + {args: [add stash-added.txt], compare: true} + {write: 'stage stash\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {write: 'unstaged stash\n', file: 'agent.txt'} + {args: [stash push -m staged], compare: true, state: true} + {args: [stash apply], compare: true, state: true} + {args: [status --short], compare: true} + {args: [reset --hard], compare: true, state: true} + {args: [stash drop], compare: true, state: true} + {write: 'quiet stash\n', file: 'agent.txt'} + {args: [stash push -q -m quiet], compare: true, state: true} + {args: [stash pop -q], compare: true, state: true} + {args: [stash push -q], compare: true, state: true} + {args: [stash drop -q], compare: true, state: true} + {args: [stash push -q], compare: true, state: true} + {args: [switch -c picks], compare: true} + {write: 'picked\n', file: 'pick.txt'} + {args: [add pick.txt], compare: true} + {args: [commit -m picked], compare: true} + {args: [switch rework], compare: true} + {args: [cherry-pick picks], compare: true, state: true} + {args: [log --oneline -n 2], compare: true} + {args: [switch picks], compare: true} + {write: 'theirs\n', file: 'agent.txt'} + {args: [commit -am theirs], compare: true} + {args: [switch rework], compare: true} + {write: 'ours\n', file: 'agent.txt'} + {args: [commit -am ours], compare: true} + {args: [cherry-pick picks], compare: true, exit: 1, state: true} + {args: [status --short], compare: true} + {args: [cherry-pick --abort], compare: true, state: true} + {args: [cherry-pick picks], compare: true, exit: 1, state: true} + {write: 'resolved\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [cherry-pick --continue], compare: true, state: true} + {args: [log --oneline -n 3], compare: true} + {args: [status --short], compare: true} + {args: [cherry-pick --abort], compare: true, exit: 128, state: true} {args: [status --bad-option], compare: true, exit: 129} {args: [not-a-command], compare: true, exit: 1} ] @@ -85,13 +240,45 @@ def main [component: path] { let native = (^git -C ($root | path join native repo) ...$args | complete) let wasi = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($root)/wasi::/' --env GIT_AUTHOR_NAME --env GIT_AUTHOR_EMAIL --env GIT_COMMITTER_NAME --env GIT_COMMITTER_EMAIL --env GIT_AUTHOR_DATE --env GIT_COMMITTER_DATE $component -C /repo ...$args | complete) let expected = ($step.exit? | default 0) - let equal = ($native.exit_code == $expected and $wasi.exit_code == $expected and (not $step.compare or $native.stdout == $wasi.stdout)) + let native_stdout = if ($step.normalize_path? | default false) { $native.stdout | str replace --all ($root | path join native repo) '/repo' } else { $native.stdout } + let equal = ($native.exit_code == $expected and $wasi.exit_code == $expected and (not $step.compare or $native_stdout == $wasi.stdout)) print $'($args | str join " "): (if $equal {"PASS"} else {"FAIL"}) native=($native.exit_code) wasi=($wasi.exit_code)' if not $equal { $failures += 1 print ({native: $native, wasi: $wasi} | to json) } + if ($step.state? | default false) { + for check in [[status --short] [log '--format=%H %P %an %ae %s' --all] [diff] [diff --cached] [show-ref] [config --get-regexp '^(branch|remote)\.']] { + let native_state = (^git -C ($root | path join native repo) ...$check | complete) + let wasi_state = (^git -C ($root | path join wasi repo) ...$check | complete) + let same = ($native_state.exit_code == $wasi_state.exit_code and $native_state.stdout == $wasi_state.stdout) + print $' state ($check | str join " "): (if $same {"PASS"} else {"FAIL"})' + if not $same { $failures += 1; print ({native: $native_state, wasi: $wasi_state} | to json) } + } + } } + # Explicit usage checks for documented forms that this CLI does not implement. + let unsupported = [ + [log --graph --oneline --all] + ] + for args in $unsupported { + let result = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($root)/wasi::/' $component -C /repo ...$args | complete) + let expected = 129 + let equal = ($result.exit_code == $expected and $result.stdout == '' and ($result.stderr | str lowercase | str contains 'usage')) + print $'($args | str join " "): (if $equal {"PASS documented usage"} else {"FAIL documented usage"}) wasi=($result.exit_code)' + if not $equal { $failures += 1; print ($result | to json) } + } + # A merge tree that differs from both parents requires a combined diff. + 'combined resolution\n' | save --force ($root | path join native/repo/file.txt) + let native = ($root | path join native/repo) + ^git -C $native add file.txt + let tree = (^git -C $native write-tree | str trim) + let merge = (^git -C $native commit-tree $tree -p HEAD -p HEAD~1 -m combined | str trim) + let result = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($root)/native/repo::/combined' $component -C /combined show $merge | complete) + let native_result = (^git -C $native show $merge | complete) + let equal = ($native_result.exit_code == 0 and $result.exit_code == 129 and $result.stdout == '' and ($result.stderr | str contains 'usage')) + print $'show combined merge: (if $equal {"PASS documented usage"} else {"FAIL documented usage"}) native=($native_result.exit_code) wasi=($result.exit_code)' + if not $equal { $failures += 1; print ($result | to json) } if $failures > 0 {error make {msg: $'($failures) workflow comparisons failed'}} } }