git: add workspace coordination forms and conversation trailers
This commit is contained in:
@@ -173,6 +173,13 @@ fn quote(prefix: &str, path: &[u8]) -> String {
|
||||
}
|
||||
// Internal byte paths preserve identity even when the RPC display is lossy.
|
||||
fn compare(old: &Snapshot, new: &Snapshot) -> Vec<(Vec<u8>, Option<Vec<u8>>, Kind)> {
|
||||
compare_with_renames(old, new, true)
|
||||
}
|
||||
fn compare_with_renames(
|
||||
old: &Snapshot,
|
||||
new: &Snapshot,
|
||||
renames: bool,
|
||||
) -> Vec<(Vec<u8>, Option<Vec<u8>>, Kind)> {
|
||||
let mut deleted: BTreeSet<_> = old
|
||||
.keys()
|
||||
.filter(|p| !new.contains_key(*p))
|
||||
@@ -187,7 +194,7 @@ fn compare(old: &Snapshot, new: &Snapshot) -> Vec<(Vec<u8>, Option<Vec<u8>>, Kin
|
||||
// Git does not pair empty blobs as renames by default.
|
||||
let empty =
|
||||
gix::objs::compute_hash(entry.id.kind(), gix::objs::Kind::Blob, b"").ok();
|
||||
let source = if Some(entry.id) == empty {
|
||||
let source = if !renames || Some(entry.id) == empty {
|
||||
None
|
||||
} else {
|
||||
deleted.iter().find(|p| old.get(*p) == Some(entry)).cloned()
|
||||
@@ -383,6 +390,9 @@ fn worktree(repo: &gix::Repository, index: &Snapshot, filter: Option<&str>) -> R
|
||||
Ok(snapshot)
|
||||
}
|
||||
pub fn diff(p: DiffParams) -> Result<Vec<FileDiff>> {
|
||||
diff_with_renames(p, true)
|
||||
}
|
||||
pub fn diff_with_renames(p: DiffParams, renames: bool) -> Result<Vec<FileDiff>> {
|
||||
if let Some(path) = &p.path {
|
||||
repo::validate_path(path)?;
|
||||
}
|
||||
@@ -425,7 +435,7 @@ pub fn diff(p: DiffParams) -> Result<Vec<FileDiff>> {
|
||||
}
|
||||
};
|
||||
let mut result = Vec::new();
|
||||
for (path, source, status) in compare(&old, &new) {
|
||||
for (path, source, status) in compare_with_renames(&old, &new, renames) {
|
||||
let before_path = source.as_ref().unwrap_or(&path);
|
||||
if !matches_path(&path, p.path.as_deref()) && !matches_path(before_path, p.path.as_deref())
|
||||
{
|
||||
|
||||
+268
-44
@@ -59,6 +59,7 @@ fn identity(name: Option<String>, email: Option<String>) -> Result<Identity> {
|
||||
mod cherry_pick;
|
||||
mod history;
|
||||
mod local;
|
||||
mod plumbing;
|
||||
mod stash;
|
||||
use clap::{Parser, Subcommand};
|
||||
#[derive(Parser)]
|
||||
@@ -87,6 +88,39 @@ enum Command {
|
||||
Fetch {
|
||||
#[arg(default_value = "origin")]
|
||||
remote: String,
|
||||
revision: Option<String>,
|
||||
},
|
||||
MergeBase {
|
||||
#[arg(long)]
|
||||
is_ancestor: bool,
|
||||
a: String,
|
||||
b: String,
|
||||
},
|
||||
RevList {
|
||||
#[arg(long)]
|
||||
count: bool,
|
||||
revisions: Vec<String>,
|
||||
},
|
||||
SymbolicRef {
|
||||
#[arg(long)]
|
||||
short: bool,
|
||||
name: String,
|
||||
},
|
||||
UpdateRef {
|
||||
name: String,
|
||||
new: String,
|
||||
old: Option<String>,
|
||||
},
|
||||
Clean {
|
||||
#[arg(short = 'f')]
|
||||
force: bool,
|
||||
#[arg(short = 'd')]
|
||||
directories: bool,
|
||||
},
|
||||
CheckIgnore {
|
||||
#[arg(long)]
|
||||
no_index: bool,
|
||||
paths: Vec<String>,
|
||||
},
|
||||
Push {
|
||||
#[arg(short = 'u', long)]
|
||||
@@ -113,6 +147,8 @@ enum Command {
|
||||
value: Option<String>,
|
||||
},
|
||||
RevParse {
|
||||
#[arg(long)]
|
||||
verify: bool,
|
||||
#[arg(long)]
|
||||
show_toplevel: bool,
|
||||
#[arg(long)]
|
||||
@@ -124,6 +160,10 @@ enum Command {
|
||||
revisions: Vec<String>,
|
||||
},
|
||||
Status {
|
||||
#[arg(long, default_value = "normal")]
|
||||
untracked_files: String,
|
||||
#[arg(short = 'z')]
|
||||
nul: bool,
|
||||
#[arg(short, long)]
|
||||
short: bool,
|
||||
#[arg(long, num_args = 0..=1, default_missing_value = "v1", require_equals = true)]
|
||||
@@ -159,10 +199,16 @@ enum Command {
|
||||
no_gpg_sign: bool,
|
||||
},
|
||||
Log {
|
||||
#[arg(long)]
|
||||
name_only: bool,
|
||||
#[arg(long)]
|
||||
no_renames: bool,
|
||||
#[arg(long)]
|
||||
diff_merges: Option<String>,
|
||||
#[arg(long)]
|
||||
oneline: bool,
|
||||
#[arg(short = 'n', default_value = "50")]
|
||||
limit: usize,
|
||||
#[arg(short = 'n')]
|
||||
limit: Option<usize>,
|
||||
#[arg(long)]
|
||||
format: Option<String>,
|
||||
#[arg(long)]
|
||||
@@ -176,6 +222,14 @@ enum Command {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
Diff {
|
||||
#[arg(long)]
|
||||
numstat: bool,
|
||||
#[arg(long)]
|
||||
no_renames: bool,
|
||||
#[arg(long)]
|
||||
diff_filter: Option<String>,
|
||||
#[arg(short = 'z')]
|
||||
nul: bool,
|
||||
#[arg(long, alias = "staged")]
|
||||
cached: bool,
|
||||
#[arg(long)]
|
||||
@@ -189,6 +243,8 @@ enum Command {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
Branch {
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
#[arg(long)]
|
||||
show_current: bool,
|
||||
#[arg(short = 'a')]
|
||||
@@ -208,12 +264,18 @@ enum Command {
|
||||
target: String,
|
||||
},
|
||||
Checkout {
|
||||
#[arg(long)]
|
||||
detach: bool,
|
||||
#[arg(long, short = 'f')]
|
||||
force: bool,
|
||||
#[arg(short = 'b')]
|
||||
create: bool,
|
||||
target: Option<String>,
|
||||
paths: Vec<String>,
|
||||
},
|
||||
Merge {
|
||||
#[arg(long)]
|
||||
no_edit: bool,
|
||||
#[arg(long)]
|
||||
ff_only: bool,
|
||||
#[arg(long)]
|
||||
@@ -239,7 +301,14 @@ enum Command {
|
||||
#[command(subcommand)]
|
||||
command: Worktree,
|
||||
},
|
||||
LsFiles,
|
||||
LsFiles {
|
||||
#[arg(long)]
|
||||
others: bool,
|
||||
#[arg(long)]
|
||||
exclude_standard: bool,
|
||||
#[arg(short = 'z')]
|
||||
nul: bool,
|
||||
},
|
||||
Reset {
|
||||
#[arg(long, conflicts_with_all = ["hard", "mixed"])]
|
||||
soft: bool,
|
||||
@@ -290,11 +359,14 @@ enum Command {
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Worktree {
|
||||
Prune,
|
||||
List {
|
||||
#[arg(long)]
|
||||
porcelain: bool,
|
||||
},
|
||||
Add {
|
||||
#[arg(long)]
|
||||
detach: bool,
|
||||
#[arg(short = 'b')]
|
||||
branch: Option<String>,
|
||||
path: String,
|
||||
@@ -481,12 +553,74 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
abort,
|
||||
revision,
|
||||
} => return cherry_pick::run(&repository, revision, resume, abort, quiet),
|
||||
Command::LsFiles => {
|
||||
Command::MergeBase { a, b, is_ancestor } => {
|
||||
return plumbing::merge_base(&repository, &a, &b, is_ancestor);
|
||||
}
|
||||
Command::RevList { count, revisions } => {
|
||||
let mut included = std::collections::BTreeSet::new();
|
||||
let mut excluded = std::collections::BTreeSet::new();
|
||||
for revision in revisions {
|
||||
let (tip, hidden) = plumbing::range(&repository, &revision)?;
|
||||
included.extend(plumbing::ancestors(&repository, tip)?);
|
||||
excluded.extend(hidden);
|
||||
}
|
||||
included.retain(|id| !excluded.contains(id));
|
||||
if count {
|
||||
println!("{}", included.len());
|
||||
} else {
|
||||
for id in included {
|
||||
println!("{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::SymbolicRef { short, name } => {
|
||||
let reference = repository
|
||||
.find_reference(name.as_str())
|
||||
.map_err(|e| error("symbolic-ref", e))?;
|
||||
if let gix::refs::TargetRef::Symbolic(target) = reference.target() {
|
||||
println!(
|
||||
"{}",
|
||||
if short {
|
||||
target.shorten().to_string()
|
||||
} else {
|
||||
target.as_bstr().to_string()
|
||||
}
|
||||
);
|
||||
} else {
|
||||
if !quiet {
|
||||
eprintln!("fatal: ref {name} is not a symbolic ref");
|
||||
}
|
||||
return Ok(if quiet { 1 } else { 128 });
|
||||
}
|
||||
}
|
||||
Command::UpdateRef { name, new, old } => {
|
||||
plumbing::update_ref(&repository, &name, &new, old.as_deref())?
|
||||
}
|
||||
Command::CheckIgnore { no_index, paths } => {
|
||||
return plumbing::check_ignore(&repository, &paths, no_index);
|
||||
}
|
||||
Command::Clean { force, directories } => {
|
||||
if !force {
|
||||
return Err(error("clean", "requires -f"));
|
||||
}
|
||||
plumbing::clean(&repository, directories, quiet)?;
|
||||
}
|
||||
Command::LsFiles {
|
||||
others,
|
||||
exclude_standard: _,
|
||||
nul,
|
||||
} => {
|
||||
if others {
|
||||
for path in changes::status(&repository)?.untracked {
|
||||
print!("{path}{}", if nul { "\0" } else { "\n" });
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let index = repository
|
||||
.index_or_empty()
|
||||
.map_err(|e| error("ls-files", e))?;
|
||||
for entry in index.entries() {
|
||||
println!("{}", entry.path(&index));
|
||||
print!("{}{}", entry.path(&index), if nul { "\0" } else { "\n" });
|
||||
}
|
||||
}
|
||||
Command::Config { key, value, .. } => {
|
||||
@@ -511,6 +645,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
Command::RevParse {
|
||||
verify,
|
||||
show_toplevel,
|
||||
git_dir,
|
||||
abbrev_ref,
|
||||
@@ -531,10 +666,11 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
);
|
||||
} else {
|
||||
for revision in revisions {
|
||||
let id = repository
|
||||
.rev_parse_single(revision.as_str())
|
||||
.map_err(|e| error("revision", e))?
|
||||
.to_string();
|
||||
let id = match repository.rev_parse_single(revision.as_str()) {
|
||||
Ok(id) => id.to_string(),
|
||||
Err(_) if verify && quiet => return Ok(1),
|
||||
Err(e) => return Err(error("revision", e)),
|
||||
};
|
||||
println!("{}", if short { &id[..7] } else { &id });
|
||||
}
|
||||
}
|
||||
@@ -591,7 +727,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
eprintln!("usage: git commit -m <message> | -F <file> | --amend --no-edit");
|
||||
return Ok(129);
|
||||
};
|
||||
let message = message.trim_end().to_owned();
|
||||
let message = plumbing::trailer(&message);
|
||||
if !amend && !allow_empty && merge::pending_parent(&repository)?.is_none() {
|
||||
let status = changes::status(&repository)?;
|
||||
if status.staged.is_empty()
|
||||
@@ -666,6 +802,8 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
})?);
|
||||
}
|
||||
Command::Status {
|
||||
untracked_files,
|
||||
nul,
|
||||
short,
|
||||
porcelain,
|
||||
branch,
|
||||
@@ -681,6 +819,13 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
.collect();
|
||||
let mut untracked = std::collections::BTreeSet::new();
|
||||
for path in status.untracked {
|
||||
if untracked_files == "no" {
|
||||
continue;
|
||||
}
|
||||
if untracked_files == "all" {
|
||||
untracked.insert(path);
|
||||
continue;
|
||||
}
|
||||
let collapsed = path
|
||||
.match_indices('/')
|
||||
.map(|(i, _)| &path[..=i])
|
||||
@@ -714,7 +859,11 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
);
|
||||
}
|
||||
let mut rows = std::collections::BTreeMap::new();
|
||||
let mut sources = std::collections::BTreeMap::new();
|
||||
for c in status.staged {
|
||||
if let Some(source) = c.old_path {
|
||||
sources.insert(c.path.clone(), source);
|
||||
}
|
||||
rows.entry(c.path).or_insert([' '; 2])[0] = code(&c.status);
|
||||
}
|
||||
for c in status.unstaged {
|
||||
@@ -729,12 +878,24 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
for untracked in [false, true] {
|
||||
for (p, c) in &rows {
|
||||
if (c == &['?', '?']) == untracked {
|
||||
println!("{}{} {p}", c[0], c[1]);
|
||||
if let Some(source) = sources.get(p) {
|
||||
if nul {
|
||||
print!("{}{} {p}\0{source}\0", c[0], c[1]);
|
||||
} else {
|
||||
println!("{}{} {source} -> {p}", c[0], c[1]);
|
||||
}
|
||||
} else {
|
||||
print!("{}{} {p}{}", c[0], c[1], if nul { "\0" } else { "\n" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Diff {
|
||||
numstat,
|
||||
no_renames,
|
||||
diff_filter,
|
||||
nul,
|
||||
cached,
|
||||
stat,
|
||||
name_only,
|
||||
@@ -748,22 +909,41 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
} else {
|
||||
(revisions.first().cloned(), revisions.get(1).cloned())
|
||||
};
|
||||
let diffs = changes::diff(changes::DiffParams {
|
||||
repo: path,
|
||||
from,
|
||||
to,
|
||||
staged: Some(cached),
|
||||
path: paths.first().cloned(),
|
||||
context: None,
|
||||
})?;
|
||||
if diff_filter.as_deref() == Some("U") {
|
||||
for p in changes::status(&repository)?.conflicted {
|
||||
print!("{p}{}", if nul { "\0" } else { "\n" });
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let diffs = changes::diff_with_renames(
|
||||
changes::DiffParams {
|
||||
repo: path,
|
||||
from,
|
||||
to,
|
||||
staged: Some(cached),
|
||||
path: paths.first().cloned(),
|
||||
context: None,
|
||||
},
|
||||
!no_renames,
|
||||
)?;
|
||||
if name_only || name_status {
|
||||
for diff in diffs {
|
||||
if name_status {
|
||||
println!("{}\t{}", code(&diff.status), diff.path);
|
||||
print!(
|
||||
"{}{}{}{}",
|
||||
code(&diff.status),
|
||||
if nul { "\0" } else { "\t" },
|
||||
diff.path,
|
||||
if nul { "\0" } else { "\n" }
|
||||
);
|
||||
} else {
|
||||
println!("{}", diff.path);
|
||||
print!("{}{}", diff.path, if nul { "\0" } else { "\n" });
|
||||
}
|
||||
}
|
||||
} else if numstat {
|
||||
for diff in &diffs {
|
||||
output::numstat(diff, nul);
|
||||
}
|
||||
} else if stat {
|
||||
output::stat(&diffs, false);
|
||||
} else {
|
||||
@@ -773,6 +953,9 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
Command::Log {
|
||||
name_only,
|
||||
no_renames,
|
||||
diff_merges,
|
||||
oneline,
|
||||
limit,
|
||||
format,
|
||||
@@ -782,13 +965,10 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
revision,
|
||||
paths,
|
||||
} => {
|
||||
let commit = repository
|
||||
.rev_parse_single(revision.as_deref().unwrap_or("HEAD"))
|
||||
.map_err(|e| error("log", e))?
|
||||
.object()
|
||||
.map_err(|e| error("log", e))?
|
||||
.peel_to_commit()
|
||||
.map_err(|e| error("log", e))?;
|
||||
let limit = limit.unwrap_or(usize::MAX);
|
||||
let (tip, excluded) =
|
||||
plumbing::range(&repository, revision.as_deref().unwrap_or("HEAD"))?;
|
||||
let commit = repository.find_commit(tip).map_err(|e| error("log", e))?;
|
||||
let ids = if let Some(path) = paths.first() {
|
||||
history::paths(&repository, commit.id, path)?
|
||||
} else {
|
||||
@@ -800,16 +980,28 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
))
|
||||
.all()
|
||||
.map_err(|e| error("log", e))?
|
||||
.filter(|info| {
|
||||
info.as_ref()
|
||||
.map_or(true, |info| !excluded.contains(&info.id))
|
||||
})
|
||||
.take(limit)
|
||||
.map(|info| info.map(|info| info.id).map_err(|e| error("log", e)))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
};
|
||||
let mut first = true;
|
||||
for id in ids.into_iter().take(limit) {
|
||||
for id in ids
|
||||
.into_iter()
|
||||
.filter(|id| !excluded.contains(id))
|
||||
.take(limit)
|
||||
{
|
||||
let c = repository.find_commit(id).map_err(|e| error("log", e))?;
|
||||
let decoded = c.decode().map_err(|e| error("log", e))?;
|
||||
let message = decoded.message.to_str_lossy();
|
||||
let diffs = if patch || stat || !paths.is_empty() {
|
||||
let diffs = if (patch || stat || name_only || !paths.is_empty())
|
||||
&& (decoded.parents.len() <= 1
|
||||
|| diff_merges.as_deref() == Some("first-parent")
|
||||
|| !name_only)
|
||||
{
|
||||
let old = match decoded.parents.first() {
|
||||
Some(id) => id.to_string(),
|
||||
None => repository
|
||||
@@ -817,14 +1009,17 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
.map_err(|e| error("log tree", e))?
|
||||
.to_string(),
|
||||
};
|
||||
changes::diff(changes::DiffParams {
|
||||
repo: path.clone(),
|
||||
from: Some(old),
|
||||
to: Some(c.id.to_string()),
|
||||
staged: None,
|
||||
path: paths.first().cloned(),
|
||||
context: None,
|
||||
})?
|
||||
changes::diff_with_renames(
|
||||
changes::DiffParams {
|
||||
repo: path.clone(),
|
||||
from: Some(old),
|
||||
to: Some(c.id.to_string()),
|
||||
staged: None,
|
||||
path: paths.first().cloned(),
|
||||
context: None,
|
||||
},
|
||||
!no_renames,
|
||||
)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -867,7 +1062,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
} else {
|
||||
format!(" ({})", labels.join(", "))
|
||||
};
|
||||
let text = template
|
||||
let text = plumbing::format(template, &message)
|
||||
.replace("%H", &c.id.to_string())
|
||||
.replace("%h", &c.id.to_string()[..7])
|
||||
.replace("%s", message.lines().next().unwrap_or(""))
|
||||
@@ -897,6 +1092,12 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
output::commit(&c)?;
|
||||
first = false;
|
||||
}
|
||||
if name_only && !diffs.is_empty() {
|
||||
println!();
|
||||
for diff in &diffs {
|
||||
println!("{}", diff.path);
|
||||
}
|
||||
}
|
||||
if patch || stat {
|
||||
println!();
|
||||
if stat {
|
||||
@@ -911,6 +1112,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
Command::Branch {
|
||||
list,
|
||||
delete,
|
||||
force_delete,
|
||||
show_current,
|
||||
@@ -938,7 +1140,8 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
if let Some(name) = name {
|
||||
if let Some(name) = name.as_ref().filter(|_| !list) {
|
||||
let name = name.clone();
|
||||
if delete || force_delete {
|
||||
let id = branches::resolve(&repository, &name)?.to_string();
|
||||
branches::delete(
|
||||
@@ -964,6 +1167,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
let pattern = name.as_deref();
|
||||
let head = head_info(&repository)?;
|
||||
for branch in repository
|
||||
.references()
|
||||
@@ -978,6 +1182,12 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if list
|
||||
&& pattern
|
||||
.is_some_and(|pattern| branch.name().shorten().to_string() != pattern)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
println!(
|
||||
"{} {}",
|
||||
if head.branch.as_deref()
|
||||
@@ -997,6 +1207,8 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
Command::Checkout {
|
||||
detach,
|
||||
force,
|
||||
create,
|
||||
target,
|
||||
mut paths,
|
||||
@@ -1021,9 +1233,14 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
&repository,
|
||||
branches::CheckoutParams {
|
||||
repo: path,
|
||||
target: required(target, "checkout target")?,
|
||||
target: if detach {
|
||||
branches::resolve(&repository, &required(target, "checkout target")?)?
|
||||
.to_string()
|
||||
} else {
|
||||
required(target, "checkout target")?
|
||||
},
|
||||
create: Some(create),
|
||||
force: None,
|
||||
force: Some(force),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
@@ -1040,6 +1257,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
)?;
|
||||
}
|
||||
Command::Merge {
|
||||
no_edit: _,
|
||||
ff_only,
|
||||
no_ff,
|
||||
no_commit,
|
||||
@@ -1058,7 +1276,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
merge::MergeParams {
|
||||
repo: path.clone(),
|
||||
rev: required(revision, "merge")?,
|
||||
message,
|
||||
message: message.map(|m| format!("{}\n", m.trim_end())),
|
||||
ff: Some(if ff_only {
|
||||
merge::FastForward::Only
|
||||
} else if no_ff {
|
||||
@@ -1154,6 +1372,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
Command::Worktree { command } => match command {
|
||||
Worktree::Prune => worktrees::prune(&repository)?,
|
||||
Worktree::List { porcelain } => {
|
||||
let common = repository
|
||||
.common_dir()
|
||||
@@ -1185,6 +1404,9 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
.unwrap_or(0)
|
||||
+ 2;
|
||||
for p in paths {
|
||||
let p = p
|
||||
.canonicalize()
|
||||
.map_err(|e| error("worktree list path", e))?;
|
||||
let linked = repo::open(&p.to_string_lossy())?;
|
||||
let head = head_info(&linked)?;
|
||||
let id = head.id.unwrap_or_else(|| "0".repeat(40));
|
||||
@@ -1206,6 +1428,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
Worktree::Add {
|
||||
detach,
|
||||
branch,
|
||||
path: target,
|
||||
revision,
|
||||
@@ -1226,6 +1449,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
branch,
|
||||
detached: Some(detach),
|
||||
from: revision,
|
||||
adopt: None,
|
||||
},
|
||||
@@ -1324,7 +1548,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Fetch { remote } => network::fetch(&path, &remote)?,
|
||||
Command::Fetch { remote, revision } => network::fetch(&path, &remote, revision.as_deref())?,
|
||||
Command::Push {
|
||||
set_upstream,
|
||||
force,
|
||||
|
||||
@@ -16,10 +16,10 @@ struct FetchResult {
|
||||
copied_objects: Option<usize>,
|
||||
updated_refs: Option<Vec<String>>,
|
||||
}
|
||||
pub(crate) fn fetch(path: &str, remote: &str) -> Result<()> {
|
||||
pub(crate) fn fetch(path: &str, remote: &str, revision: Option<&str>) -> Result<()> {
|
||||
let mut repo = repo::open(path)?;
|
||||
repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
|
||||
perform(&repo, remote, None, false)?;
|
||||
perform(&repo, remote, None, false, revision)?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn clone(url: &str, path: &str) -> Result<()> {
|
||||
@@ -32,7 +32,7 @@ pub(crate) fn clone(url: &str, path: &str) -> Result<()> {
|
||||
remotes::set(&repo, "origin", url, true)?;
|
||||
let mut repo = repo::open(&name)?;
|
||||
repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
|
||||
let fetched = perform(&repo, "origin", None, false)?;
|
||||
let fetched = perform(&repo, "origin", None, false, None)?;
|
||||
let branch = fetched
|
||||
.default_branch
|
||||
.ok_or_else(|| error("clone", "server HEAD does not name a branch"))?;
|
||||
@@ -61,9 +61,10 @@ fn perform(
|
||||
remote_name: &str,
|
||||
depth: Option<u32>,
|
||||
prune: bool,
|
||||
revision: Option<&str>,
|
||||
) -> Result<Fetched> {
|
||||
remotes::validate_name(remote_name)?;
|
||||
let remote = repo
|
||||
let mut remote = repo
|
||||
.find_remote(remote_name)
|
||||
.map_err(|_| error("fetch", "configured remote not found"))?;
|
||||
let url = remote
|
||||
@@ -83,6 +84,21 @@ fn perform(
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(revision) = revision {
|
||||
let source = if revision.starts_with("refs/") || revision.len() == 40 {
|
||||
revision.to_owned()
|
||||
} else {
|
||||
format!("refs/heads/{revision}")
|
||||
};
|
||||
let spec = if let Some(branch) = source.strip_prefix("refs/heads/") {
|
||||
format!("+{source}:refs/remotes/{remote_name}/{branch}")
|
||||
} else {
|
||||
source
|
||||
};
|
||||
remote
|
||||
.replace_refspecs([spec.as_bytes().as_bstr()], gix::remote::Direction::Fetch)
|
||||
.map_err(|e| error("fetch refspec", e))?;
|
||||
}
|
||||
let transport: Box<dyn gix_transport::client::blocking_io::Transport> =
|
||||
if crate::ssh::is_ssh(&url) {
|
||||
Box::new(crate::ssh::connection(
|
||||
@@ -137,6 +153,13 @@ fn perform(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let fetch_heads: Vec<_> = refs
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let (name, id, _) = r.unpack();
|
||||
id.map(|id| (name.to_string(), id.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let advertised_refs = refs.len();
|
||||
let empty = heads.is_empty() && refs.iter().all(|r| r.unpack().1.is_none());
|
||||
if let Some(depth) = depth {
|
||||
@@ -170,7 +193,26 @@ fn perform(
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(branch) = default_branch.as_deref().filter(|b| heads.contains(*b)) {
|
||||
let fetch_head = fetch_heads
|
||||
.iter()
|
||||
.filter(|(name, _)| {
|
||||
revision.map_or(name.starts_with("refs/heads/"), |r| {
|
||||
name == r || name == &format!("refs/heads/{r}")
|
||||
})
|
||||
})
|
||||
.map(|(name, id)| {
|
||||
format!(
|
||||
"{id}\t\tbranch '{}' of {url}\n",
|
||||
name.trim_start_matches("refs/heads/")
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
std::fs::write(repo.git_dir().join("FETCH_HEAD"), fetch_head)
|
||||
.map_err(|e| error("FETCH_HEAD", e))?;
|
||||
if let Some(branch) = default_branch
|
||||
.as_deref()
|
||||
.filter(|b| revision.is_none() && heads.contains(*b))
|
||||
{
|
||||
use gix::refs::{
|
||||
Target,
|
||||
transaction::{Change, PreviousValue, RefEdit},
|
||||
|
||||
@@ -122,6 +122,21 @@ fn label(kind: &Kind) -> &'static str {
|
||||
Kind::Renamed => "renamed:",
|
||||
}
|
||||
}
|
||||
pub(crate) fn numstat(diff: &FileDiff, nul: bool) {
|
||||
let hunks = diff.patch.lines().skip_while(|l| !l.starts_with("@@"));
|
||||
let added = hunks.clone().filter(|l| l.starts_with('+')).count();
|
||||
let removed = hunks.filter(|l| l.starts_with('-')).count();
|
||||
let (added, removed) = if diff.binary {
|
||||
("-".into(), "-".into())
|
||||
} else {
|
||||
(added.to_string(), removed.to_string())
|
||||
};
|
||||
print!(
|
||||
"{added}\t{removed}\t{}{}",
|
||||
diff.path,
|
||||
if nul { "\0" } else { "\n" }
|
||||
);
|
||||
}
|
||||
pub(crate) fn stat(diffs: &[FileDiff], summary: bool) {
|
||||
summarize(diffs, true, summary);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Revision sets, reference transactions, and ignored-path queries for coordination.
|
||||
use crate::{Result, branches, repo::error};
|
||||
use gix::bstr::ByteSlice;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub(crate) fn ancestors(
|
||||
repo: &gix::Repository,
|
||||
tip: gix::ObjectId,
|
||||
) -> Result<BTreeSet<gix::ObjectId>> {
|
||||
repo.find_commit(tip)
|
||||
.map_err(|e| error("revision", e))?
|
||||
.ancestors()
|
||||
.use_commit_graph(false)
|
||||
.all()
|
||||
.map_err(|e| error("revision walk", e))?
|
||||
.map(|info| info.map(|i| i.id).map_err(|e| error("revision walk", e)))
|
||||
.collect()
|
||||
}
|
||||
pub(crate) fn range(
|
||||
repo: &gix::Repository,
|
||||
input: &str,
|
||||
) -> Result<(gix::ObjectId, BTreeSet<gix::ObjectId>)> {
|
||||
let (from, to) = input.split_once("..").unwrap_or(("", input));
|
||||
let tip = branches::resolve(repo, if to.is_empty() { "HEAD" } else { to })?;
|
||||
let excluded = if input.contains("..") {
|
||||
ancestors(
|
||||
repo,
|
||||
branches::resolve(repo, if from.is_empty() { "HEAD" } else { from })?,
|
||||
)?
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
};
|
||||
Ok((tip, excluded))
|
||||
}
|
||||
pub(crate) fn merge_base(
|
||||
repo: &gix::Repository,
|
||||
a: &str,
|
||||
b: &str,
|
||||
is_ancestor: bool,
|
||||
) -> Result<i32> {
|
||||
let a = branches::resolve(repo, a)?;
|
||||
let b = branches::resolve(repo, b)?;
|
||||
let base = repo.merge_base(a, b).map_err(|e| error("merge-base", e))?;
|
||||
if is_ancestor {
|
||||
return Ok(if base.is_some_and(|id| id.detach() == a) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
});
|
||||
}
|
||||
if let Some(base) = base {
|
||||
println!("{base}");
|
||||
Ok(0)
|
||||
} else {
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
pub(crate) fn update_ref(
|
||||
repo: &gix::Repository,
|
||||
name: &str,
|
||||
new: &str,
|
||||
old: Option<&str>,
|
||||
) -> Result<()> {
|
||||
use gix::refs::{
|
||||
Target,
|
||||
transaction::{Change, PreviousValue, RefEdit},
|
||||
};
|
||||
let expected = match old {
|
||||
Some(value) if value == "0".repeat(40) => PreviousValue::MustNotExist,
|
||||
Some(value) => PreviousValue::MustExistAndMatch(Target::Object(
|
||||
repo.rev_parse_single(value)
|
||||
.map_err(|e| error("old revision", e))?
|
||||
.detach(),
|
||||
)),
|
||||
None => PreviousValue::Any,
|
||||
};
|
||||
branches::edit_refs(
|
||||
repo,
|
||||
vec![RefEdit {
|
||||
name: name.try_into().map_err(|e| error("reference", e))?,
|
||||
deref: true,
|
||||
change: Change::Update {
|
||||
log: Default::default(),
|
||||
expected,
|
||||
new: Target::Object(
|
||||
repo.rev_parse_single(new)
|
||||
.map_err(|e| error("new revision", e))?
|
||||
.detach(),
|
||||
),
|
||||
},
|
||||
}],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn trailer(message: &str) -> String {
|
||||
if let Ok(value) = std::env::var("SAUSAGE_CONVERSATION") {
|
||||
let message = message.trim_end();
|
||||
let line = format!("Sausage-Conversation: {value}");
|
||||
if message.lines().any(|l| l == line) {
|
||||
return message.into();
|
||||
}
|
||||
let last = message.rsplit("\n\n").next().unwrap_or(message);
|
||||
let has_trailers = message.contains("\n\n")
|
||||
&& last
|
||||
.lines()
|
||||
.all(|l| l.split_once(':').is_some_and(|(k, _)| !k.contains(' ')));
|
||||
format!(
|
||||
"{message}{}{line}",
|
||||
if has_trailers { "\n" } else { "\n\n" }
|
||||
)
|
||||
} else {
|
||||
message.trim_end().into()
|
||||
}
|
||||
}
|
||||
pub(crate) fn format(template: &str, message: &str) -> String {
|
||||
let trailer = "%(trailers:key=Sausage-Conversation,valueonly)";
|
||||
let values = message
|
||||
.rsplit_once("\n\n")
|
||||
.map(|(_, block)| {
|
||||
block
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
line.split_once(':')
|
||||
.filter(|(key, _)| key.eq_ignore_ascii_case("Sausage-Conversation"))
|
||||
.map(|(_, value)| format!("{}\n", value.trim()))
|
||||
})
|
||||
.collect::<String>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let text = template.replace(trailer, &values);
|
||||
let mut output = String::new();
|
||||
let mut rest = text.as_str();
|
||||
while let Some(i) = rest.find("%x") {
|
||||
output.push_str(&rest[..i]);
|
||||
rest = &rest[i..];
|
||||
if let Some(hex) = rest.get(2..4).and_then(|s| u8::from_str_radix(s, 16).ok()) {
|
||||
output.push(hex as char);
|
||||
rest = &rest[4..];
|
||||
} else {
|
||||
output.push_str("%x");
|
||||
rest = &rest[2..];
|
||||
}
|
||||
}
|
||||
output.push_str(rest);
|
||||
output
|
||||
}
|
||||
pub(crate) fn check_ignore(
|
||||
repo: &gix::Repository,
|
||||
paths: &[String],
|
||||
no_index: bool,
|
||||
) -> Result<i32> {
|
||||
let index = repo.index_or_empty().map_err(|e| error("index", e))?;
|
||||
let mut excludes = repo
|
||||
.excludes(
|
||||
&index,
|
||||
None,
|
||||
gix::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped,
|
||||
)
|
||||
.map_err(|e| error("excludes", e))?;
|
||||
let mut found = false;
|
||||
for path in paths {
|
||||
crate::repo::validate_path(path)?;
|
||||
if !no_index
|
||||
&& index
|
||||
.entries()
|
||||
.iter()
|
||||
.any(|e| e.path(&index) == path.as_bytes().as_bstr())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if excludes
|
||||
.at_entry(path.as_str(), None)
|
||||
.map_err(|e| error("ignore", e))?
|
||||
.is_excluded()
|
||||
{
|
||||
println!("{path}");
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
Ok(if found { 0 } else { 1 })
|
||||
}
|
||||
pub(crate) fn clean(repo: &gix::Repository, directories: bool, quiet: bool) -> Result<()> {
|
||||
let root = crate::repo::worktree(repo)?;
|
||||
let index = repo.index_or_empty().map_err(|e| error("index", e))?;
|
||||
let mut excludes = repo
|
||||
.excludes(
|
||||
&index,
|
||||
None,
|
||||
gix::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped,
|
||||
)
|
||||
.map_err(|e| error("excludes", e))?;
|
||||
fn walk(
|
||||
root: &std::path::Path,
|
||||
relative: &std::path::Path,
|
||||
index: &gix::index::State,
|
||||
excludes: &mut gix::AttributeStack<'_>,
|
||||
directories: bool,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
for entry in
|
||||
std::fs::read_dir(root.join(relative)).map_err(|e| error("clean directory", e))?
|
||||
{
|
||||
let entry = entry.map_err(|e| error("clean entry", e))?;
|
||||
if entry.file_name() == ".git" {
|
||||
continue;
|
||||
}
|
||||
let path = relative.join(entry.file_name());
|
||||
let name = path.to_string_lossy();
|
||||
let dir = entry
|
||||
.file_type()
|
||||
.map_err(|e| error("clean type", e))?
|
||||
.is_dir();
|
||||
let tracked = index.entries().iter().any(|e| {
|
||||
let p = e.path(index);
|
||||
p == name.as_bytes().as_bstr()
|
||||
|| (dir && p.starts_with(format!("{name}/").as_bytes()))
|
||||
});
|
||||
if excludes
|
||||
.at_entry(
|
||||
name.as_ref(),
|
||||
if dir {
|
||||
Some(gix::index::entry::Mode::DIR)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
.map_err(|e| error("clean ignore", e))?
|
||||
.is_excluded()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if dir {
|
||||
if entry.path().join(".git").exists() || (!directories && !tracked) {
|
||||
continue;
|
||||
}
|
||||
walk(root, &path, index, excludes, directories, quiet)?;
|
||||
if directories
|
||||
&& !tracked
|
||||
&& std::fs::read_dir(entry.path())
|
||||
.map_err(|e| error("clean directory", e))?
|
||||
.next()
|
||||
.is_none()
|
||||
{
|
||||
std::fs::remove_dir(entry.path()).map_err(|e| error("clean directory", e))?;
|
||||
}
|
||||
} else if !tracked {
|
||||
std::fs::remove_file(entry.path()).map_err(|e| error("clean file", e))?;
|
||||
if !quiet {
|
||||
println!("Removing {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
walk(
|
||||
root,
|
||||
std::path::Path::new(""),
|
||||
&index,
|
||||
&mut excludes,
|
||||
directories,
|
||||
quiet,
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use std::{
|
||||
/// Attach a linked worktree, or adopt existing bytes onto an unborn branch.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct AddParams {
|
||||
pub detached: Option<bool>,
|
||||
/// Repository name or absolute worktree/bare path under /repos/ or /plugins/.
|
||||
pub repo: String,
|
||||
/// Absolute worktree path under /plugins/ or /repos/, without dot segments.
|
||||
@@ -150,10 +151,28 @@ pub(crate) struct AttachParams {
|
||||
/// Existing short local branch name.
|
||||
pub branch: String,
|
||||
}
|
||||
pub(crate) fn prune(repo: &gix::Repository) -> Result<()> {
|
||||
let base = repo.common_dir().join("worktrees");
|
||||
if !base.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(base).map_err(|e| error("worktree prune", e))? {
|
||||
let dir = entry.map_err(|e| error("worktree prune", e))?.path();
|
||||
if dir.join("locked").exists() {
|
||||
continue;
|
||||
}
|
||||
let gitdir = read(&dir.join("gitdir"))?;
|
||||
if !Path::new(gitdir.trim()).exists() {
|
||||
fs::remove_dir_all(dir).map_err(|e| error("worktree prune", e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn attach(repo: &gix::Repository, p: AttachParams) -> Result<WorktreeInfo> {
|
||||
add_with(
|
||||
repo,
|
||||
AddParams {
|
||||
detached: None,
|
||||
repo: p.repo,
|
||||
path: p.path,
|
||||
branch: p.branch,
|
||||
@@ -170,20 +189,24 @@ fn add_with(repo: &gix::Repository, p: AddParams, keep_files: bool) -> Result<Wo
|
||||
let path = absolute(&p.path)?;
|
||||
let common = canonical(repo.common_dir())?;
|
||||
separate(&path, &common)?;
|
||||
let detached = p.detached.unwrap_or(false);
|
||||
let branch = branches::branch_name(&p.branch)?;
|
||||
ensure_available(repo, &branch)?;
|
||||
if !detached {
|
||||
ensure_available(repo, &branch)?;
|
||||
}
|
||||
if let Some(current_path) = repo.workdir() {
|
||||
if crate::head_info(repo)?.branch.as_deref() == Some(&branch) {
|
||||
if !detached && crate::head_info(repo)?.branch.as_deref() == Some(&branch) {
|
||||
return Err(error(
|
||||
"branch checked out",
|
||||
format!("{branch} is checked out at {}", current_path.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
let exists = repo
|
||||
.try_find_reference(branch.as_str())
|
||||
.map_err(|e| error("worktree branch", e))?
|
||||
.is_some();
|
||||
let exists = !detached
|
||||
&& repo
|
||||
.try_find_reference(branch.as_str())
|
||||
.map_err(|e| error("worktree branch", e))?
|
||||
.is_some();
|
||||
let adopt = p.adopt.unwrap_or(false);
|
||||
if adopt && (exists || p.from.is_some()) {
|
||||
return Err(error(
|
||||
@@ -296,8 +319,12 @@ fn add_with(repo: &gix::Repository, p: AddParams, keep_files: bool) -> Result<Wo
|
||||
&linked,
|
||||
branches::CheckoutParams {
|
||||
repo: p.path.clone(),
|
||||
target: p.branch.clone(),
|
||||
create: Some(!exists),
|
||||
target: if detached {
|
||||
id.to_string()
|
||||
} else {
|
||||
p.branch.clone()
|
||||
},
|
||||
create: Some(!exists && !detached),
|
||||
force: None,
|
||||
},
|
||||
)?
|
||||
|
||||
Reference in New Issue
Block a user