git: match native agent workflows

This commit is contained in:
2026-11-06 19:07:28 +00:00
parent b5462cc366
commit 6daa53cca4
11 changed files with 1641 additions and 75 deletions
+12
View File
@@ -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)?;
+180
View File
@@ -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)
}
+77
View File
@@ -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
View File
@@ -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);
}
}
}
}
+283
View File
@@ -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(())
}
+21 -1
View File
@@ -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");
}
+7 -5
View File
@@ -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",
+12 -2
View File
@@ -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)?;
+227
View File
@@ -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)
}