git: add workspace coordination forms and conversation trailers

This commit is contained in:
2026-11-07 00:44:49 +00:00
parent aaf6d9b54f
commit 54229adad2
8 changed files with 770 additions and 67 deletions
+23 -1
View File
@@ -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 <ref> <new> [<old>]` with an atomic old-value check.
- `worktree add --detach <path> <rev>`, `prune`, and `list --porcelain`.
- `checkout --detach --force <rev>` and `clean -fdq`.
- `merge --no-edit -m <message>` 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 <name>` and `check-ignore --no-index <path>`.
- `fetch <remote> <ref>` 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.
+12 -2
View File
@@ -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<u8>, Option<Vec<u8>>, Kind)> {
compare_with_renames(old, new, true)
}
fn compare_with_renames(
old: &Snapshot,
new: &Snapshot,
renames: bool,
) -> Vec<(Vec<u8>, Option<Vec<u8>>, 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<u8>, Option<Vec<u8>>, 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<Vec<FileDiff>> {
diff_with_renames(p, true)
}
pub fn diff_with_renames(p: DiffParams, renames: bool) -> Result<Vec<FileDiff>> {
if let Some(path) = &p.path {
repo::validate_path(path)?;
}
@@ -425,7 +435,7 @@ pub fn diff(p: DiffParams) -> Result<Vec<FileDiff>> {
}
};
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())
{
+268 -44
View File
@@ -59,6 +59,7 @@ fn identity(name: Option<String>, email: Option<String>) -> Result<Identity> {
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<String>,
},
MergeBase {
#[arg(long)]
is_ancestor: bool,
a: String,
b: String,
},
RevList {
#[arg(long)]
count: bool,
revisions: Vec<String>,
},
SymbolicRef {
#[arg(long)]
short: bool,
name: String,
},
UpdateRef {
name: String,
new: String,
old: Option<String>,
},
Clean {
#[arg(short = 'f')]
force: bool,
#[arg(short = 'd')]
directories: bool,
},
CheckIgnore {
#[arg(long)]
no_index: bool,
paths: Vec<String>,
},
Push {
#[arg(short = 'u', long)]
@@ -113,6 +147,8 @@ enum Command {
value: Option<String>,
},
RevParse {
#[arg(long)]
verify: bool,
#[arg(long)]
show_toplevel: bool,
#[arg(long)]
@@ -124,6 +160,10 @@ enum Command {
revisions: Vec<String>,
},
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<String>,
#[arg(long)]
oneline: bool,
#[arg(short = 'n', default_value = "50")]
limit: usize,
#[arg(short = 'n')]
limit: Option<usize>,
#[arg(long)]
format: Option<String>,
#[arg(long)]
@@ -176,6 +222,14 @@ enum Command {
paths: Vec<String>,
},
Diff {
#[arg(long)]
numstat: bool,
#[arg(long)]
no_renames: bool,
#[arg(long)]
diff_filter: Option<String>,
#[arg(short = 'z')]
nul: bool,
#[arg(long, alias = "staged")]
cached: bool,
#[arg(long)]
@@ -189,6 +243,8 @@ enum Command {
paths: Vec<String>,
},
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<String>,
paths: Vec<String>,
},
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<String>,
path: String,
@@ -481,12 +553,74 @@ pub fn run(cli: Cli) -> Result<i32> {
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<i32> {
}
}
Command::RevParse {
verify,
show_toplevel,
git_dir,
abbrev_ref,
@@ -531,10 +666,11 @@ pub fn run(cli: Cli) -> Result<i32> {
);
} 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<i32> {
eprintln!("usage: git commit -m <message> | -F <file> | --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<i32> {
})?);
}
Command::Status {
untracked_files,
nul,
short,
porcelain,
branch,
@@ -681,6 +819,13 @@ pub fn run(cli: Cli) -> Result<i32> {
.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<i32> {
);
}
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<i32> {
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<i32> {
} 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<i32> {
}
}
Command::Log {
name_only,
no_renames,
diff_merges,
oneline,
limit,
format,
@@ -782,13 +965,10 @@ pub fn run(cli: Cli) -> Result<i32> {
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<i32> {
))
.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::<Result<Vec<_>>>()?
};
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<i32> {
.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<i32> {
} 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<i32> {
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<i32> {
}
}
Command::Branch {
list,
delete,
force_delete,
show_current,
@@ -938,7 +1140,8 @@ pub fn run(cli: Cli) -> Result<i32> {
}
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<i32> {
)?;
}
} 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<i32> {
{
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<i32> {
}
}
Command::Checkout {
detach,
force,
create,
target,
mut paths,
@@ -1021,9 +1233,14 @@ pub fn run(cli: Cli) -> Result<i32> {
&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<i32> {
)?;
}
Command::Merge {
no_edit: _,
ff_only,
no_ff,
no_commit,
@@ -1058,7 +1276,7 @@ pub fn run(cli: Cli) -> Result<i32> {
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<i32> {
}
}
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<i32> {
.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<i32> {
}
}
Worktree::Add {
detach,
branch,
path: target,
revision,
@@ -1226,6 +1449,7 @@ pub fn run(cli: Cli) -> Result<i32> {
.to_string_lossy()
.into_owned(),
branch,
detached: Some(detach),
from: revision,
adopt: None,
},
@@ -1324,7 +1548,7 @@ pub fn run(cli: Cli) -> Result<i32> {
}
}
}
Command::Fetch { remote } => network::fetch(&path, &remote)?,
Command::Fetch { remote, revision } => network::fetch(&path, &remote, revision.as_deref())?,
Command::Push {
set_upstream,
force,
+47 -5
View File
@@ -16,10 +16,10 @@ struct FetchResult {
copied_objects: Option<usize>,
updated_refs: Option<Vec<String>>,
}
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<u32>,
prune: bool,
revision: Option<&str>,
) -> Result<Fetched> {
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<dyn gix_transport::client::blocking_io::Transport> =
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::<String>();
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},
+15
View File
@@ -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);
}
+263
View File
@@ -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<BTreeSet<gix::ObjectId>> {
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<gix::ObjectId>)> {
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<i32> {
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::<String>()
})
.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<i32> {
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,
)
}
+35 -8
View File
@@ -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<bool>,
/// 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<WorktreeInfo> {
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<Wo
let path = absolute(&p.path)?;
let common = canonical(repo.common_dir())?;
separate(&path, &common)?;
let detached = p.detached.unwrap_or(false);
let branch = branches::branch_name(&p.branch)?;
ensure_available(repo, &branch)?;
if !detached {
ensure_available(repo, &branch)?;
}
if let Some(current_path) = repo.workdir() {
if crate::head_info(repo)?.branch.as_deref() == Some(&branch) {
if !detached && crate::head_info(repo)?.branch.as_deref() == Some(&branch) {
return Err(error(
"branch checked out",
format!("{branch} is checked out at {}", current_path.display()),
));
}
}
let exists = repo
.try_find_reference(branch.as_str())
.map_err(|e| error("worktree branch", e))?
.is_some();
let exists = !detached
&& repo
.try_find_reference(branch.as_str())
.map_err(|e| error("worktree branch", e))?
.is_some();
let adopt = p.adopt.unwrap_or(false);
if adopt && (exists || p.from.is_some()) {
return Err(error(
@@ -296,8 +319,12 @@ fn add_with(repo: &gix::Repository, p: AddParams, keep_files: bool) -> Result<Wo
&linked,
branches::CheckoutParams {
repo: p.path.clone(),
target: p.branch.clone(),
create: Some(!exists),
target: if detached {
id.to_string()
} else {
p.branch.clone()
},
create: Some(!exists && !detached),
force: None,
},
)?
+107 -7
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env nu
# Compare a deterministic agent workflow with native Git. Fail on every difference.
def main [component: path] {
def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-test-http-server'] {
let component = ($component | path expand)
let wit = (^wasm-tools component wit $component | complete)
if $wit.exit_code != 0 {error make {msg: $wit.stderr}}
@@ -11,6 +11,14 @@ def main [component: path] {
print ($imports | str join (char newline))
let root = (mktemp -d -p /tmp/p1-git workflow.XXXXXX)
print $'Fixture: ($root)'
let server_root = ($root | path join server)
mkdir $server_root
let ready = ($root | path join port)
let server_job = (job spawn { ^$server $server_root $ready })
let deadline = ((date now) + 10sec)
while not ($ready | path exists) { if (date now) > $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
}