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