git: add workspace coordination forms and conversation trailers

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