git: add workspace coordination forms and conversation trailers
This commit is contained in:
+23
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user