userland/git: git-compatible CLI on v2's gix code (T10)

Local workflow, smart-HTTP clone/fetch/push and public HTTPS clone/fetch match
native git in stdout and exit codes. Only WASI imports (wasi:http, exit-with-code).
This commit is contained in:
2026-11-06 16:07:57 +00:00
parent 0fae0f9b10
commit 4b27e9afd5
204 changed files with 37736 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
//! Branch/HEAD plumbing. All mutators are called under the common .git lock.
use crate::Result;
use crate::{HeadInfo, checkout::write_tree_to_worktree, head_info, repo::error};
/// Switch a branch, tag, or commit; optionally create a branch at HEAD.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct CheckoutParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Branch, tag or commit revision; with create=true, the new branch name.
pub target: String,
/// Create a new branch at HEAD and switch to it (default false).
pub create: Option<bool>,
/// Discard overwritten tracked changes, never untracked files (default false).
pub force: Option<bool>,
}
/// Create a local branch, optionally switching the worktree to it.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct BranchCreateParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Short new branch name.
pub name: String,
/// Starting commit revision, default HEAD.
pub from: Option<String>,
/// Switch to the new branch, refusing overwritten dirty changes (default false).
pub checkout: Option<bool>,
}
/// Delete a local branch.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct BranchDeleteParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Short existing branch name.
pub name: String,
/// Allow deletion when not merged into HEAD (default false). Current branch is always protected.
pub force: Option<bool>,
}
/// Result of a checkout or branch creation.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct CheckoutResult {
/// HEAD after the operation.
pub head: HeadInfo,
/// Full branch reference created, if any.
pub created: Option<String>,
/// Unsupported worktree features (symlinks/submodules/chmod); raw content has no filters or eol conversion.
pub warnings: Vec<String>,
}
/// Deleted branch identity.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct BranchDeleted {
/// Full reference deleted.
pub reference: String,
}
/// Reflogs require an identity, but switching branches must not require configuring one.
/// With no repository identity, leave reflogs untouched and report that explicitly; never invent an author.
pub(crate) fn edit_refs(
repo: &gix::Repository,
edits: Vec<gix::refs::transaction::RefEdit>,
) -> Result<Vec<String>> {
let committer = repo
.committer()
.transpose()
.map_err(|e| error("reflog identity", e))?;
if let Some(committer) = committer {
repo.edit_references_as(edits, Some(committer))
.map_err(|e| error("checkout refs", e))?;
Ok(Vec::new())
} else {
let mut local = repo.clone();
local.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
local
.edit_references_as(edits, None)
.map_err(|e| error("checkout refs", e))?;
Ok(vec![
"reflog not written: no repository committer identity configured".into(),
])
}
}
pub(crate) fn branch_name(name: &str) -> Result<String> {
if name.is_empty() || name.starts_with('-') || name.starts_with("refs/") || name == "HEAD" {
return Err(error("branch name", "expected short local branch name"));
}
let full = format!("refs/heads/{name}");
let _: gix::refs::FullName = full
.as_str()
.try_into()
.map_err(|e| error("branch name", e))?;
Ok(full)
}
fn unused(repo: &gix::Repository, name: &str) -> Result<()> {
if repo
.try_find_reference(name)
.map_err(|e| error("branch lookup", e))?
.is_some()
{
return Err(error("branch create", "branch already exists"));
}
Ok(())
}
pub(crate) fn resolve(repo: &gix::Repository, rev: &str) -> Result<gix::ObjectId> {
Ok(repo
.rev_parse_single(rev)
.map_err(|e| error("checkout revision", e))?
.object()
.map_err(|e| error("checkout object", e))?
.peel_to_kind(gix::objs::Kind::Commit)
.map_err(|e| error("checkout commit", e))?
.id)
}
fn old_tree(repo: &gix::Repository) -> Result<Option<gix::ObjectId>> {
if repo
.head()
.map_err(|e| error("checkout HEAD", e))?
.is_unborn()
{
return Ok(None);
}
let id = resolve(repo, "HEAD")?;
Ok(Some(
repo.find_commit(id)
.map_err(|e| error("checkout HEAD", e))?
.tree_id()
.map_err(|e| error("checkout HEAD tree", e))?
.detach(),
))
}
fn switch(
repo: &gix::Repository,
id: gix::ObjectId,
branch: Option<&str>,
create: bool,
force: bool,
) -> Result<Vec<String>> {
crate::merge::ensure_idle(repo)?;
if let Some(branch) = branch {
crate::worktrees::ensure_available(repo, branch)?;
}
let tree = repo
.find_commit(id)
.map_err(|e| error("checkout commit", e))?
.tree_id()
.map_err(|e| error("checkout tree", e))?
.detach();
// Validate identity before publishing worktree/index. edit_refs resolves the same
// immutable repository config again when publishing refs, but must not discover a
// malformed identity only after files have changed.
repo.committer()
.transpose()
.map_err(|e| error("reflog identity", e))?;
let mut warnings = write_tree_to_worktree(repo, old_tree(repo)?, tree, force)?;
// Index already published. Refs last, using gix transactions (including packed refs and reflogs).
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
let mut edits = Vec::new();
if create {
let name = branch.ok_or_else(|| error("checkout", "missing new branch"))?;
edits.push(RefEdit {
name: name.try_into().map_err(|e| error("branch name", e))?,
deref: false,
change: Change::Update {
log: Default::default(),
expected: PreviousValue::MustNotExist,
new: Target::Object(id),
},
});
}
let target = match branch {
Some(name) => Target::Symbolic(name.try_into().map_err(|e| error("branch name", e))?),
None => Target::Object(id),
};
edits.push(RefEdit {
name: "HEAD".try_into().map_err(|e| error("HEAD", e))?,
deref: false,
change: Change::Update {
log: Default::default(),
expected: PreviousValue::Any,
new: target,
},
});
warnings.extend(edit_refs(repo, edits)?);
Ok(warnings)
}
pub(crate) fn checkout(repo: &gix::Repository, p: CheckoutParams) -> Result<CheckoutResult> {
crate::repo::worktree(repo)?;
let create = p.create.unwrap_or(false);
let (id, branch) = if create {
let name = branch_name(&p.target)?;
crate::worktrees::ensure_available(repo, &name)?;
unused(repo, &name)?;
(resolve(repo, "HEAD")?, Some(name))
} else {
// Local branches take precedence over tags; tags and all other revisions detach HEAD.
let name = if p.target.starts_with("refs/heads/") {
p.target.clone()
} else {
format!("refs/heads/{}", p.target)
};
// Revision operators are not valid reference names. Only probe a local
// branch when its full name validates; otherwise let rev_parse interpret it.
let reference = if <gix::refs::FullName as TryFrom<&str>>::try_from(name.as_str()).is_ok() {
crate::worktrees::ensure_available(repo, &name)?;
repo.try_find_reference(name.as_str())
.map_err(|e| error("checkout branch", e))?
} else {
None
};
if let Some(mut reference) = reference {
let id = reference
.peel_to_id()
.map_err(|e| error("checkout branch", e))?
.detach();
(id, Some(name))
} else {
(resolve(repo, &p.target)?, None)
}
};
let warnings = switch(
repo,
id,
branch.as_deref(),
create,
p.force.unwrap_or(false),
)?;
Ok(CheckoutResult {
head: head_info(repo)?,
created: if create { branch } else { None },
warnings,
})
}
pub(crate) fn create(repo: &gix::Repository, p: BranchCreateParams) -> Result<CheckoutResult> {
let name = branch_name(&p.name)?;
if p.checkout.unwrap_or(false) {
crate::worktrees::ensure_available(repo, &name)?;
}
unused(repo, &name)?;
let id = resolve(repo, p.from.as_deref().unwrap_or("HEAD"))?;
let warnings = if p.checkout.unwrap_or(false) {
switch(repo, id, Some(&name), true, false)?
} else {
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
edit_refs(
repo,
vec![RefEdit {
name: name
.as_str()
.try_into()
.map_err(|e| error("branch name", e))?,
deref: false,
change: Change::Update {
log: Default::default(),
expected: PreviousValue::MustNotExist,
new: Target::Object(id),
},
}],
)?
};
Ok(CheckoutResult {
head: head_info(repo)?,
created: Some(name),
warnings,
})
}
pub(crate) fn delete(repo: &gix::Repository, p: BranchDeleteParams) -> Result<BranchDeleted> {
crate::merge::ensure_idle(repo)?;
let name = branch_name(&p.name)?;
crate::worktrees::ensure_available(repo, &name)?;
if head_info(repo)?.branch.as_deref() == Some(&name) {
return Err(error("branch delete", "cannot delete current branch"));
}
let mut reference = repo
.find_reference(name.as_str())
.map_err(|e| error("branch delete", e))?;
let tip = reference
.peel_to_id()
.map_err(|e| error("branch delete", e))?
.detach();
if !p.force.unwrap_or(false) {
let head = resolve(repo, "HEAD")?;
// Explicit traversal avoids commit-graph mmap on WASI; includes every parent.
let walk = repo
.find_commit(head)
.map_err(|e| error("branch merged", e))?
.ancestors()
.use_commit_graph(false)
.all()
.map_err(|e| error("branch merged", e))?;
let mut merged = false;
for entry in walk {
if entry.map_err(|e| error("branch merged", e))?.id == tip {
merged = true;
break;
}
}
if !merged {
return Err(error(
"branch delete",
"branch is not merged into HEAD; use force",
));
}
}
reference.delete().map_err(|e| error("branch delete", e))?;
Ok(BranchDeleted { reference: name })
}
+600
View File
@@ -0,0 +1,600 @@
//! Read-only snapshots and patches. Ordinary Git state only; never refresh/write the index.
use crate::Result;
use crate::{
HeadInfo, head_info,
repo::{self, error},
};
use gix::bstr::ByteSlice;
use std::collections::{BTreeMap, BTreeSet};
/// Change classification. Renames are exact-content matches, not similarity guesses.
#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq, Debug)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
Added,
Modified,
Deleted,
Renamed,
}
/// One changed repository-relative path.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Change {
/// Destination path (old path for a deletion), invalid UTF-8 replaced.
pub path: String,
/// Classification; mode/type changes are modified.
pub status: Kind,
/// Source path for exact-content renames.
pub old_path: Option<String>,
}
/// Repository status without index refresh or other writes.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Status {
/// Symbolic, detached or unborn HEAD.
pub head: HeadInfo,
/// HEAD tree to stage-zero index, excluding conflicts.
pub staged: Vec<Change>,
/// Index to worktree; conflicts listed separately.
pub unstaged: Vec<Change>,
/// Individual untracked files, excluding ignored paths.
pub untracked: Vec<String>,
/// Paths with nonzero index stages.
pub conflicted: Vec<String>,
}
/// Diff query with mutually exclusive modes.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct DiffParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Old revision; must be supplied together with to, without staged=true.
pub from: Option<String>,
/// New revision (commit, tag or tree).
pub to: Option<String>,
/// Compare HEAD to index instead of index to worktree; default false.
pub staged: Option<bool>,
/// Literal relative file/directory filter, matches either rename endpoint.
pub path: Option<String>,
/// Unchanged lines around each hunk; default 3.
pub context: Option<u32>,
}
/// One changed file and its patch.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct FileDiff {
/// Destination path, or old path for deletion.
pub path: String,
/// Added, modified, deleted or exact-content renamed.
pub status: Kind,
/// Rename source, otherwise null.
pub old_path: Option<String>,
/// True for NUL-containing or non-UTF-8 content; patch is empty.
pub binary: bool,
/// Unified patch with file headers and mode/rename metadata. Empty for binary files.
pub patch: String,
}
#[derive(Clone, PartialEq, Eq)]
struct Entry {
id: gix::ObjectId,
mode: u32,
}
type Snapshot = BTreeMap<Vec<u8>, Entry>;
fn index(repo: &gix::Repository) -> Result<(Snapshot, BTreeSet<Vec<u8>>)> {
let index = repo.index_or_empty().map_err(|e| error("index", e))?;
let mut entries = Snapshot::new();
let mut conflicts = BTreeSet::new();
for entry in index.entries() {
let path = entry.path(&index).to_vec();
if entry.mode == gix::index::entry::Mode::DIR
|| entry
.flags
.contains(gix::index::entry::Flags::INTENT_TO_ADD)
{
return Err(error(
"index",
"sparse-directory and intent-to-add entries are not supported by status/diff",
));
}
if entry.stage() != gix::index::entry::Stage::Unconflicted {
conflicts.insert(path);
} else {
entries.insert(
path,
Entry {
id: entry.id,
mode: entry.mode.bits(),
},
);
}
}
Ok((entries, conflicts))
}
fn tree(repo: &gix::Repository, rev: &str) -> Result<Snapshot> {
if rev == "HEAD" && repo.head().map_err(|e| error("diff HEAD", e))?.is_unborn() {
return Ok(Snapshot::new());
}
let obj = repo
.rev_parse_single(rev)
.map_err(|e| error("diff revision", e))?
.object()
.map_err(|e| error("diff object", e))?
.peel_to_kind(gix::objs::Kind::Tree)
.map_err(|e| error("diff tree", e))?;
let mut result = Snapshot::new();
let mut pending = vec![(Vec::new(), obj.id)];
while let Some((prefix, id)) = pending.pop() {
let tree = repo.find_tree(id).map_err(|e| error("diff tree", e))?;
for entry in tree.iter() {
let entry = entry.map_err(|e| error("diff entry", e))?;
let mut path = prefix.clone();
path.extend_from_slice(entry.filename());
if entry.mode().is_tree() {
path.push(b'/');
pending.push((path, entry.oid().to_owned()));
} else {
result.insert(
path,
Entry {
id: entry.oid().to_owned(),
mode: entry.mode().kind() as u32,
},
);
}
}
}
Ok(result)
}
fn display(path: &[u8]) -> String {
String::from_utf8_lossy(path).into_owned()
}
/// Git C-style byte quoting, so patch paths remain exact even when RPC display paths are lossy.
fn quote(prefix: &str, path: &[u8]) -> String {
if prefix
.as_bytes()
.iter()
.chain(path)
.all(|b| matches!(b, 32..=126) && !matches!(b, b'"' | b'\\'))
{
return format!("{prefix}{}", display(path));
}
let mut out = String::from("\"");
for &byte in prefix.as_bytes().iter().chain(path) {
match byte {
b'"' => out.push_str("\\\""),
b'\\' => out.push_str("\\\\"),
b'\n' => out.push_str("\\n"),
b'\r' => out.push_str("\\r"),
b'\t' => out.push_str("\\t"),
32..=126 => out.push(byte as char),
_ => out.push_str(&format!("\\{byte:03o}")),
}
}
out.push('"');
out
}
// 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)> {
let mut deleted: BTreeSet<_> = old
.keys()
.filter(|p| !new.contains_key(*p))
.cloned()
.collect();
let mut result = Vec::new();
for (path, entry) in new {
match old.get(path) {
Some(before) if before == entry => (),
Some(_) => result.push((path.clone(), None, Kind::Modified)),
None => {
// 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 {
None
} else {
deleted.iter().find(|p| old.get(*p) == Some(entry)).cloned()
};
if let Some(source) = source {
deleted.remove(&source);
result.push((path.clone(), Some(source), Kind::Renamed));
} else {
result.push((path.clone(), None, Kind::Added));
}
}
}
}
result.extend(deleted.into_iter().map(|p| (p, None, Kind::Deleted)));
result.sort_by(|a, b| a.0.cmp(&b.0));
result
}
fn changes(old: &Snapshot, new: &Snapshot) -> Vec<Change> {
compare(old, new)
.into_iter()
.map(|(path, source, status)| Change {
path: display(&path),
old_path: source.map(|p| display(&p)),
status,
})
.collect()
}
pub fn status(repo: &gix::Repository) -> Result<Status> {
repo::worktree(repo)?;
let (index, conflicts) = index(repo)?;
let mut head = tree(repo, "HEAD")?;
for path in &conflicts {
head.remove(path);
}
let mut result = Status {
head: head_info(repo)?,
staged: changes(&head, &index),
unstaged: vec![],
untracked: vec![],
conflicted: conflicts.iter().map(|p| display(p)).collect(),
};
use gix::status::index_worktree::{Item, iter::Summary};
let iter = repo
.status(gix::progress::Discard)
.map_err(|e| error("status", e))?
.untracked_files(gix::status::UntrackedFiles::Files)
.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
.map_err(|e| error("status", e))?;
for item in iter {
let item = item.map_err(|e| error("status", e))?;
let path = item.rela_path().to_str_lossy().into_owned();
let Some(summary) = item.summary() else {
continue;
};
if conflicts.contains(item.rela_path().as_bytes()) {
continue;
}
if matches!(item, Item::DirectoryContents { .. }) {
result.untracked.push(path);
continue;
}
if summary == Summary::Conflict {
continue;
}
result.unstaged.push(Change {
path,
old_path: None,
status: if summary == Summary::Removed {
Kind::Deleted
} else {
Kind::Modified
},
});
}
result.unstaged.sort_by(|a, b| a.path.cmp(&b.path));
result.untracked.sort();
Ok(result)
}
fn blob(repo: &gix::Repository, entry: Option<&Entry>) -> Result<Vec<u8>> {
match entry {
None => Ok(vec![]),
Some(e) if e.mode == 0o160000 => Ok(format!("Subproject commit {}\n", e.id).into_bytes()),
Some(e) => Ok(repo
.find_blob(e.id)
.map_err(|e| error("diff blob", e))?
.data
.clone()),
}
}
fn matches_path(path: &[u8], filter: Option<&str>) -> bool {
filter.is_none_or(|filter| {
path == filter.as_bytes()
|| path
.strip_prefix(filter.as_bytes())
.is_some_and(|rest| rest.starts_with(b"/"))
})
}
// Read at most one worktree file at a time. No repository-wide blob-byte cache.
fn worktree_file(
repo: &gix::Repository,
path: &[u8],
entry: &Entry,
) -> Result<Option<(Entry, Vec<u8>)>> {
let root = repo.workdir().ok_or_else(|| error("diff", "no worktree"))?;
let caps = repo
.filesystem_options()
.map_err(|e| error("diff filesystem", e))?;
if entry.mode == 0o160000 {
return Err(error(
"diff",
"worktree submodule patches are not supported",
));
}
let file = root.join(gix::path::from_bstr(path.as_bstr()).map_err(|e| error("diff path", e))?);
let meta = match std::fs::symlink_metadata(&file) {
Ok(m) => m,
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(None);
}
Err(e) => return Err(error("diff stat", e)),
};
if meta.is_dir() {
return Ok(None);
}
let bytes = if meta.file_type().is_symlink() {
gix::path::into_bstr(std::fs::read_link(&file).map_err(|e| error("diff link", e))?)
.map_err(|e| error("diff link path", e))?
.into_owned()
.to_vec()
} else {
std::fs::read(&file).map_err(|e| error("diff read", e))?
};
let mode = if meta.file_type().is_symlink() {
0o120000
} else if entry.mode == 0o120000 && caps.symlink {
0o100644
} else {
entry.mode
};
#[cfg(unix)]
let mode = if mode != 0o120000 && caps.executable_bit {
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o111 != 0 {
0o100755
} else {
0o100644
}
} else {
mode
};
let id = gix::objs::compute_hash(repo.object_hash(), gix::objs::Kind::Blob, &bytes)
.map_err(|e| error("diff hash", e))?;
Ok(Some((Entry { id, mode }, bytes)))
}
fn worktree(repo: &gix::Repository, index: &Snapshot, filter: Option<&str>) -> Result<Snapshot> {
repo::worktree(repo)?;
let mut snapshot = index.clone();
for (path, entry) in index {
// Filter before stat, read, or hashing (including unsupported submodules).
if !matches_path(path, filter) {
continue;
}
match worktree_file(repo, path, entry)? {
Some((entry, _bytes)) => {
snapshot.insert(path.clone(), entry);
}
None => {
snapshot.remove(path);
}
}
}
Ok(snapshot)
}
pub fn diff(p: DiffParams) -> Result<Vec<FileDiff>> {
if let Some(path) = &p.path {
repo::validate_path(path)?;
}
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),
(None, None, staged) => {
repo::worktree(&repo)?;
let (index, conflicts) = index(&repo)?;
if !conflicts.is_empty() {
return Err(error(
"diff",
"unmerged index: resolve conflicts first (see status.conflicted)",
));
}
if staged {
(tree(&repo, "HEAD")?, index, false)
} else {
let new = worktree(&repo, &index, p.path.as_deref())?;
(index, new, true)
}
}
_ => {
return Err(error(
"diff",
"supply both from and to, without staged=true, or neither revision",
));
}
};
let mut result = Vec::new();
for (path, source, status) in compare(&old, &new) {
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())
{
continue;
}
let before = old.get(before_path);
let after = new.get(&path);
let a = blob(&repo, before)?;
let b = if work {
match after {
Some(expected) => {
let (actual, bytes) = worktree_file(&repo, &path, expected)?
.ok_or_else(|| error("diff", "worktree changed during diff; retry"))?;
if &actual != expected {
return Err(error("diff", "worktree changed during diff; retry"));
}
bytes
}
None => Vec::new(),
}
} else {
blob(&repo, after)?
};
let binary = a.contains(&0)
|| b.contains(&0)
|| std::str::from_utf8(&a).is_err()
|| std::str::from_utf8(&b).is_err();
let mut patch = String::new();
if !binary {
patch.push_str(&format!(
"diff --git {} {}\n",
quote("a/", before_path),
quote("b/", &path)
));
match (before, after) {
(None, Some(e)) => patch.push_str(&format!("new file mode {:06o}\n", e.mode)),
(Some(e), None) => patch.push_str(&format!("deleted file mode {:06o}\n", e.mode)),
(Some(a), Some(b)) if a.mode != b.mode => patch.push_str(&format!(
"old mode {:06o}\nnew mode {:06o}\n",
a.mode, b.mode
)),
_ => (),
}
if status == Kind::Renamed {
patch.push_str(&format!(
"similarity index 100%\nrename from {}\nrename to {}\n",
quote("", before_path),
quote("", &path)
));
}
if a != b {
let old_id = before
.map(|e| e.id.to_string())
.unwrap_or_else(|| "0".repeat(40));
let new_id = after
.map(|e| e.id.to_string())
.unwrap_or_else(|| "0".repeat(40));
patch.push_str(&format!(
"index {}..{}{}\n",
&old_id[..7],
&new_id[..7],
match (before, after) {
(Some(a), Some(b)) if a.mode == b.mode => format!(" {:06o}", a.mode),
_ => String::new(),
}
));
patch.push_str(&format!(
"--- {}\n+++ {}\n",
if before.is_some() {
quote("a/", before_path)
} else {
"/dev/null".into()
},
if after.is_some() {
quote("b/", &path)
} else {
"/dev/null".into()
}
));
patch.push_str(&unified(
std::str::from_utf8(&a).map_err(|e| error("diff text", e))?,
std::str::from_utf8(&b).map_err(|e| error("diff text", e))?,
p.context.unwrap_or(3) as usize,
));
}
}
result.push(FileDiff {
path: display(&path),
old_path: source.map(|p| display(&p)),
status,
binary,
patch,
});
}
Ok(result)
}
/// Format imara's edit ranges, coalescing context-overlapping hunks and preserving final-newline differences.
fn unified(a: &str, b: &str, context: usize) -> String {
use gix::diff::blob::{Algorithm, Diff, InternedInput};
let left: Vec<_> = a.split_inclusive('\n').collect();
let right: Vec<_> = b.split_inclusive('\n').collect();
let mut input = InternedInput::<&str>::default();
input.update_before(left.iter().copied());
input.update_after(right.iter().copied());
let diff = Diff::compute(Algorithm::Myers, &input);
let hunks: Vec<_> = diff.hunks().collect();
let mut out = String::new();
let mut i = 0;
let line = |out: &mut String, prefix: char, s: &str| {
out.push(prefix);
out.push_str(s);
if !s.ends_with('\n') {
out.push_str("\n\\ No newline at end of file\n");
}
};
while i < hunks.len() {
let first = &hunks[i];
let start_a = (first.before.start as usize).saturating_sub(context);
let start_b = (first.after.start as usize).saturating_sub(context);
let mut last = i;
while last + 1 < hunks.len()
&& (hunks[last + 1].before.start as usize)
.saturating_sub(hunks[last].before.end as usize)
<= context.saturating_mul(2)
{
last += 1;
}
let end_a = (hunks[last].before.end as usize)
.saturating_add(context)
.min(left.len());
let end_b = (hunks[last].after.end as usize)
.saturating_add(context)
.min(right.len());
let range = |start: usize, end: usize| {
let count = end - start;
if count == 1 {
format!("{}", start + 1)
} else {
format!("{},{}", if count == 0 { start } else { start + 1 }, count)
}
};
out.push_str(&format!(
"@@ -{} +{} @@\n",
range(start_a, end_a),
range(start_b, end_b)
));
let mut cursor = start_a;
for h in &hunks[i..=last] {
for s in &left[cursor..h.before.start as usize] {
line(&mut out, ' ', s);
}
for s in &left[h.before.start as usize..h.before.end as usize] {
line(&mut out, '-', s);
}
for s in &right[h.after.start as usize..h.after.end as usize] {
line(&mut out, '+', s);
}
cursor = h.before.end as usize;
}
for s in &left[cursor..end_a] {
line(&mut out, ' ', s);
}
i = last + 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unified_context_coalescing_and_final_newline() {
assert_eq!(
unified(
"a\nb\nc\nd\ne\nf\ng\nh\ni\n",
"a\nB\nc\nd\ne\nf\ng\nH\ni\n",
1
),
"@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n@@ -7,3 +7,3 @@\n g\n-h\n+H\n i\n"
);
assert_eq!(
unified("a\nb\nc\nd\ne\n", "a\nB\nc\nD\ne\n", 1),
"@@ -1,5 +1,5 @@\n a\n-b\n+B\n c\n-d\n+D\n e\n"
);
assert_eq!(
unified("a\n", "a", 0),
"@@ -1 +1 @@\n-a\n+a\n\\ No newline at end of file\n"
);
assert_eq!(unified("a\n", "a\nb\n", 0), "@@ -1,0 +2 @@\n+b\n");
assert_eq!(unified("a\nb\n", "a\n", 0), "@@ -2 +1,0 @@\n-b\n");
assert_eq!(unified("", "x\n", usize::MAX), "@@ -0,0 +1 @@\n+x\n");
assert_eq!(quote("a/", b"plain/file"), "a/plain/file");
assert_eq!(quote("", b"with space"), "with space");
assert_eq!(
quote("a/", b"x\t\"\\\n\x01\xff"),
"\"a/x\\t\\\"\\\\\\n\\001\\377\""
);
}
}
+424
View File
@@ -0,0 +1,424 @@
//! Worktree writer shared by checkout and future clone/merge operations.
//! Caller must hold the repository .git lock. No refs are changed here.
use crate::Result;
use crate::repo::error;
use gix::bstr::ByteSlice;
use gix::index::{
State,
entry::{Mode, Stat},
};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
type Files = BTreeMap<String, (gix::ObjectId, Mode)>;
fn tree_state(repo: &gix::Repository, tree: Option<gix::ObjectId>) -> Result<State> {
match tree {
Some(id) => State::from_tree(&id, &repo.objects, Default::default())
.map_err(|e| error("checkout tree", e)),
None => Ok(State::new(repo.object_hash())),
}
}
fn files(state: &State) -> Result<Files> {
state
.entries()
.iter()
.map(|e| {
let path = e
.path(state)
.to_str()
.map_err(|e| error("checkout path UTF-8", e))?
.to_owned();
crate::repo::validate_path(&path)?;
if path
.split('/')
.any(|s| s.eq_ignore_ascii_case(".git") || s.contains('\\'))
{
return Err(error("checkout path", path));
}
Ok((path, (e.id, e.mode)))
})
.collect()
}
fn metadata(path: &Path) -> Result<Option<fs::Metadata>> {
match fs::symlink_metadata(path) {
Ok(m) => Ok(Some(m)),
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
Ok(None)
}
Err(e) => Err(error(
"checkout metadata",
format!("{}: {e}", path.display()),
)),
}
}
fn regular(mode: Mode) -> bool {
mode == Mode::FILE || mode == Mode::FILE_EXECUTABLE
}
fn clean(repo: &gix::Repository, path: &Path, expected: (gix::ObjectId, Mode)) -> Result<bool> {
let Some(meta) = metadata(path)? else {
// These entries are intentionally not materialized by this writer.
return Ok(!regular(expected.1));
};
if expected.1 == Mode::SYMLINK && meta.file_type().is_symlink() {
let target = fs::read_link(path).map_err(|e| error("checkout symlink", e))?;
let target = target
.to_str()
.ok_or_else(|| error("checkout symlink", "non-UTF-8 link target"))?;
return Ok(gix::objs::compute_hash(
repo.object_hash(),
gix::objs::Kind::Blob,
target.as_bytes(),
)
.map_err(|e| error("checkout symlink hash", e))?
== expected.0);
}
if !meta.is_file() || !regular(expected.1) {
return Ok(false);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if (meta.permissions().mode() & 0o111 != 0) != (expected.1 == Mode::FILE_EXECUTABLE) {
return Ok(false);
}
}
let bytes = fs::read(path).map_err(|e| error("checkout read", e))?;
let id = gix::objs::compute_hash(repo.object_hash(), gix::objs::Kind::Blob, &bytes)
.map_err(|e| error("checkout hash", e))?;
Ok(id == expected.0)
}
/// Verify every leaf in a directory is tracked and scheduled for removal. Never remove untracked leaves.
fn removable_dir(root: &Path, dir: &Path, removed: &BTreeSet<String>) -> Result<bool> {
for child in fs::read_dir(dir).map_err(|e| error("checkout directory", e))? {
let child = child.map_err(|e| error("checkout directory", e))?;
let path = child.path();
let meta = fs::symlink_metadata(&path).map_err(|e| error("checkout metadata", e))?;
if meta.is_dir() {
if !removable_dir(root, &path, removed)? {
return Ok(false);
}
} else if !path
.strip_prefix(root)
.ok()
.and_then(Path::to_str)
.is_some_and(|p| removed.contains(p))
{
return Ok(false);
}
}
Ok(true)
}
/// Diff trees, preflight all overwrite risks, write raw blobs, remove tracked paths and empty
/// directories, then atomically publish the index last. On I/O failure HEAD/refs remain unchanged;
/// worktree edits already completed are not rolled back. Filters/attributes are not applied.
/// Unrelated staged and unstaged edits survive a non-force switch. Force resets tracked paths,
/// but never overwrites/deletes an untracked file or follows a symlink directory.
pub(crate) fn write_tree_to_worktree(
repo: &gix::Repository,
old_tree: Option<gix::ObjectId>,
new_tree: gix::ObjectId,
force: bool,
) -> Result<Vec<String>> {
write_tree(repo, old_tree, new_tree, force, false)
}
/// Abort discards tracked changes without refusing dirty files. Paths no longer in the
/// index are untracked, even if HEAD once contained them. Preserve any such obstruction,
/// restoring the HEAD index but leaving a warning/local difference rather than losing bytes.
pub(crate) fn abort_to_tree(repo: &gix::Repository, tree: gix::ObjectId) -> Result<Vec<String>> {
write_tree(repo, None, tree, true, true)
}
fn write_tree(
repo: &gix::Repository,
old_tree: Option<gix::ObjectId>,
new_tree: gix::ObjectId,
force: bool,
preserve_obstructions: bool,
) -> Result<Vec<String>> {
let root = repo
.workdir()
.ok_or_else(|| error("checkout", "bare repository"))?;
let old = files(&tree_state(repo, old_tree)?)?;
let mut state = tree_state(repo, Some(new_tree))?;
let new = files(&state)?;
let index_path = repo.git_dir().join("index");
let existing = if index_path
.try_exists()
.map_err(|e| error("checkout index", e))?
{
Some(
gix::index::File::at(&index_path, repo.object_hash(), false, Default::default())
.map_err(|e| error("checkout index", e))?,
)
} else {
None
};
if existing.as_ref().is_some_and(|i| i.is_sparse()) {
return Err(error(
"checkout index",
"sparse indexes are unsupported; expand the index before checkout",
));
}
let indexed = existing
.as_ref()
.map(|i| files(i))
.transpose()?
.unwrap_or_default();
let mut affected: BTreeSet<String> = old
.keys()
.chain(new.keys())
.filter(|p| force || old.get(*p) != new.get(*p))
.cloned()
.collect();
if force {
affected.extend(indexed.keys().cloned());
} else {
// A staged addition can conflict structurally even when missing from the worktree.
// Include index ancestors/descendants of changed paths, so it cannot silently become a D/F conflict.
let changed = affected.clone();
affected.extend(
indexed
.keys()
.filter(|p| {
let prefix = format!("{p}/");
changed
.range(prefix.clone()..)
.next()
.is_some_and(|q| q.starts_with(&prefix))
|| Path::new(p)
.ancestors()
.skip(1)
.filter_map(Path::to_str)
.any(|q| changed.contains(q))
})
.cloned(),
);
}
let conflicts: BTreeSet<_> = existing
.as_ref()
.map(|i| {
i.entries()
.iter()
.filter(|e| e.stage_raw() != 0)
.map(|e| e.path(i).to_str_lossy().into_owned())
.collect()
})
.unwrap_or_default();
let tracked: BTreeSet<_> = old.keys().chain(indexed.keys()).cloned().collect();
let removed: BTreeSet<_> = affected
.iter()
.filter(|p| !new.get(*p).is_some_and(|e| regular(e.1)))
.cloned()
.collect();
let mut dirty = BTreeSet::new();
let mut blocked = BTreeSet::new();
let mut warnings = Vec::new();
// Validate paths in every source before touching anything, including index entries.
for path in &affected {
let dest = root.join(path);
// A symlink/file parent may be replaced only if tracked and scheduled for deletion.
let mut parent = Path::new(path).parent();
while let Some(p) = parent.filter(|p| !p.as_os_str().is_empty()) {
if let Some(m) = metadata(&root.join(p))? {
if m.file_type().is_symlink()
|| (!m.is_dir()
&& !removed.contains(p.to_str().unwrap_or(""))
&& new.get(path).is_some_and(|e| regular(e.1)))
{
blocked.insert(path.clone());
}
}
parent = p.parent();
}
// Inspect the actual worktree: a removed tracked ancestor may have been replaced
// by a directory containing untracked files. A real file parent yields None via
// metadata's NotADirectory handling, without hiding existing descendants.
let meta = metadata(&dest)?;
if let Some(m) = &meta {
if m.is_dir() && new.get(path).is_some_and(|e| regular(e.1)) {
if !removable_dir(root, &dest, &removed)? {
blocked.insert(path.clone());
}
} else if !m.is_dir() && !tracked.contains(path) {
blocked.insert(path.clone());
}
}
if !force {
if indexed.get(path) != old.get(path) && existing.is_some() {
dirty.insert(path.clone());
}
if conflicts.contains(path) {
dirty.insert(path.clone());
}
if let Some(expected) = old.get(path) {
if !clean(repo, &dest, *expected)? {
dirty.insert(path.clone());
}
}
}
}
if !blocked.is_empty() {
let paths = blocked.iter().cloned().collect::<Vec<_>>().join(", ");
if preserve_obstructions {
warnings.push(format!("abort preserved untracked/obstructing paths; HEAD index restored but worktree differs: {paths}"));
} else {
return Err(error(
"checkout untracked/obstructing paths (also protected with force)",
paths,
));
}
}
if !dirty.is_empty() {
return Err(error(
"checkout would overwrite uncommitted changes",
dirty.into_iter().collect::<Vec<_>>().join(", "),
));
}
// Read every required blob before beginning mutations (missing/corrupt objects must not partially checkout).
let mut blobs = BTreeMap::new();
for path in &affected {
if blocked.contains(path) {
continue;
}
if let Some((id, mode)) = new.get(path) {
if regular(*mode) {
blobs.insert(
path.clone(),
repo.find_blob(*id)
.map_err(|e| error("checkout blob", e))?
.data
.to_vec(),
);
} else {
warnings.push(format!(
"{path}: symlink/submodule unsupported; skipped worktree materialization"
));
}
}
}
// Remove old tracked leaves first, deepest first; never recursively delete a directory.
let mut deletions: Vec<_> = affected
.iter()
.filter(|p| tracked.contains(*p) && !blocked.contains(*p))
.collect();
deletions.sort_by_key(|p| std::cmp::Reverse(p.len()));
for path in deletions {
let dest = root.join(path);
// Ancestors may have been removed already (only affects structurally conflicting staged entries).
match fs::symlink_metadata(&dest) {
Ok(m) if !m.is_dir() => {
fs::remove_file(&dest).map_err(|e| error("checkout remove", e))?
}
Ok(_) => {}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) => {}
Err(e) => return Err(error("checkout remove", e)),
}
prune_empty(root, dest.parent());
}
for (path, bytes) in blobs {
let dest = root.join(&path);
if dest.is_dir() {
remove_empty_dirs(&dest)?;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| error("checkout mkdir", e))?;
}
fs::write(&dest, bytes).map_err(|e| error("checkout write", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = if new[&path].1 == Mode::FILE_EXECUTABLE {
0o755
} else {
0o644
};
fs::set_permissions(&dest, fs::Permissions::from_mode(mode))
.map_err(|e| error("checkout chmod", e))?;
}
}
#[cfg(not(unix))]
if affected
.iter()
.any(|p| new.get(p).is_some_and(|e| e.1 == Mode::FILE_EXECUTABLE))
{
warnings.push(
"executable bits preserved in index; filesystem chmod unavailable on this target"
.into(),
);
}
// Preserve unrelated staging, including staged additions, deletions, and conflict stages.
if let Some(index) = &existing {
state.remove_entries(|_, path, _| !affected.contains(path.to_str().unwrap_or("")));
for entry in index.entries() {
if !affected.contains(entry.path(index).to_str().unwrap_or("")) {
state.dangerously_push_entry(
entry.stat,
entry.id,
entry.flags,
entry.mode,
entry.path(index),
);
}
}
state.sort_entries();
state.remove_tree(); // Unrelated staging may differ from the target tree.
}
for (entry, path) in state.entries_mut_with_paths() {
if affected.contains(path.to_str().unwrap_or(""))
&& !blocked.contains(path.to_str().unwrap_or(""))
&& regular(entry.mode)
{
let stat = gix::index::fs::Metadata::from_path_no_follow(
&root.join(path.to_str().map_err(|e| error("checkout path", e))?),
)
.map_err(|e| error("checkout stat", e))?;
entry.stat = Stat::from_fs(&stat).map_err(|e| error("checkout stat", e))?;
}
}
gix::index::File::from_state(state, index_path)
.write(Default::default())
.map_err(|e| error("checkout index write", e))?;
Ok(warnings)
}
fn prune_empty(root: &Path, mut parent: Option<&Path>) {
while let Some(p) = parent.filter(|p| *p != root && p.starts_with(root)) {
if fs::remove_dir(p).is_err() {
break;
}
parent = p.parent();
}
}
fn remove_empty_dirs(path: &Path) -> Result<()> {
for child in fs::read_dir(path).map_err(|e| error("checkout directory", e))? {
let child = child.map_err(|e| error("checkout directory", e))?;
if child
.file_type()
.map_err(|e| error("checkout directory", e))?
.is_dir()
{
remove_empty_dirs(&child.path())?;
}
}
fs::remove_dir(path).map_err(|e| error("checkout directory not empty", e))
}
+8
View File
@@ -0,0 +1,8 @@
/// Read HEAD without mistaking an unborn branch for an I/O error.
fn head_info(repo: &gix::Repository) -> Result<HeadInfo> {
let head = repo.head().map_err(|e| error("HEAD", e))?;
Ok(HeadInfo { detached: head.is_detached(), unborn: head.is_unborn(),
branch: head.referent_name().map(|name| name.as_bstr().to_str_lossy().into_owned()),
id: head.id().map(|id| id.to_string()) })
}
+431
View File
@@ -0,0 +1,431 @@
//! Synchronous wasi:http backend. Response packs stream; replayable request bodies spool to the current directory.
//! No executor/block_on, worker threads, signal handling, or secret-bearing diagnostics.
use gix_error::ErrorExt;
use gix_transport::client::blocking_io::http::{GetResponse, Http, PostBodyDataKind, PostResponse};
use std::{
cell::RefCell,
fs::File,
io::{self, BufRead, BufReader, Cursor, Read, Seek, Write},
rc::Rc,
};
use wasi::http::{outgoing_handler, types::*};
type Shared = Rc<RefCell<Pending>>;
pub struct WasiHttp {
timeout: Option<u64>,
redirect: Rc<RefCell<Option<String>>>,
}
impl Default for WasiHttp {
fn default() -> Self {
Self {
timeout: None,
redirect: Default::default(),
}
}
}
fn err(text: impl Into<String>) -> io::Error {
io::Error::other(text.into())
}
/// The host injects credentials. Git accepts credential-free HTTP and HTTPS URLs.
pub fn clean_url(input: &str) -> io::Result<url::Url> {
let u = url::Url::parse(input).map_err(|_| err("invalid HTTP URL"))?;
if !matches!(u.scheme(), "http" | "https")
|| u.host_str().is_none()
|| !u.username().is_empty()
|| u.password().is_some()
|| u.query().is_some()
|| u.fragment().is_some()
{
return Err(err(
"use an HTTP or HTTPS repository URL without credentials, query, or fragment",
));
}
Ok(u)
}
fn status_error(status: u16, host: &str) -> io::Error {
// Other rather than PermissionDenied prevents external credential helpers.
err(format!("HTTP {status} from {host}"))
}
struct Incoming {
stream: Option<wasi::io::streams::InputStream>,
body: Option<IncomingBody>,
}
impl Read for Incoming {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
let Some(stream) = self.stream.as_ref() else {
return Ok(0);
};
loop {
match stream.blocking_read(buf.len().min(64 * 1024) as u64) {
Ok(bytes) if bytes.is_empty() => {
stream.subscribe().block();
continue;
}
Ok(bytes) => {
buf[..bytes.len()].copy_from_slice(&bytes);
return Ok(bytes.len());
}
Err(wasi::io::streams::StreamError::Closed) => {
self.stream.take();
if let Some(body) = self.body.take() {
let trailers = IncomingBody::finish(body);
trailers.subscribe().block();
match trailers.get() {
Some(Ok(Ok(_))) => {}
_ => return Err(err("HTTP response body failed")),
}
}
return Ok(0);
}
Err(_) => return Err(err("HTTP response read failed or timed out")),
}
}
}
}
impl Drop for Incoming {
fn drop(&mut self) {
self.stream.take();
self.body.take();
}
}
// State is shared only within one synchronous request, not across handlers/processes.
struct Pending {
timeout: Option<u64>,
url: String,
base: String,
headers: Vec<String>,
spool: Option<File>,
path: Option<std::path::PathBuf>,
writer_open: bool,
response_headers: Option<Cursor<Vec<u8>>>,
response: Option<BufReader<Box<dyn Read>>>,
redirect: Rc<RefCell<Option<String>>>,
}
impl Drop for Pending {
fn drop(&mut self) {
self.spool.take();
if let Some(path) = self.path.take() {
let _ = std::fs::remove_file(&path);
}
}
}
impl Pending {
fn start(&mut self) -> io::Result<()> {
if self.response.is_some() {
return Ok(());
}
if self.writer_open {
return Err(err(
"HTTP request body must be closed before reading response",
));
}
let mut target = url::Url::parse(&self.url).map_err(|_| err("invalid HTTP request URL"))?;
for attempt in 0..2 {
let response = self.send(&target)?;
let status = response.status();
if matches!(status, 301 | 302 | 303 | 307 | 308) {
if attempt == 1 {
return Err(err("HTTP redirect limit (one) exceeded"));
}
let locations = response.headers().get(&"location".to_string());
let location = locations
.first()
.and_then(|v| std::str::from_utf8(v).ok())
.ok_or_else(|| err("HTTP redirect has no valid Location"))?;
let next = target
.join(location)
.map_err(|_| err("invalid HTTP redirect"))?;
if !matches!(next.scheme(), "http" | "https")
|| (target.scheme() == "https" && next.scheme() != "https")
|| !next.username().is_empty()
|| next.password().is_some()
{
return Err(err("redirect requires an HTTPS URL without credentials"));
}
// Preserve the git endpoint suffix so subsequent POSTs use the new repository base.
let tail = self
.url
.strip_prefix(&self.base)
.ok_or_else(|| err("invalid HTTP base"))?;
let next_text = next.as_str();
let base = next_text
.strip_suffix(tail)
.ok_or_else(|| err("redirect changed the Git endpoint suffix"))?;
*self.redirect.borrow_mut() = Some(base.to_owned());
target = next;
continue;
}
if !(200..300).contains(&status) {
return Err(status_error(status, target.host_str().unwrap_or("unknown")));
}
let mut headers = Vec::new();
for (name, value) in response.headers().entries() {
headers.extend_from_slice(name.as_bytes());
headers.extend_from_slice(b": ");
headers.extend_from_slice(&value);
headers.push(b'\n');
}
let body = response
.consume()
.map_err(|_| err("could not consume HTTP response"))?;
let stream = body
.stream()
.map_err(|_| err("could not open HTTP response stream"))?;
self.response_headers = Some(Cursor::new(headers));
self.response = Some(BufReader::new(Box::new(Incoming {
stream: Some(stream),
body: Some(body),
})));
return Ok(());
}
Err(err("HTTP redirect failed"))
}
fn send(&mut self, target: &url::Url) -> io::Result<IncomingResponse> {
let fields = Fields::new();
for header in &self.headers {
let (name, value) = header
.split_once(':')
.ok_or_else(|| err("invalid HTTP header"))?;
// Never accept Authorization from Git config or URL.
if name.eq_ignore_ascii_case("authorization") {
continue;
}
fields
.append(&name.to_string(), &value.trim().as_bytes().to_vec())
.map_err(|_| err("invalid HTTP header"))?;
}
if let Some(file) = self.spool.as_ref() {
fields
.append(
"content-length",
&file.metadata()?.len().to_string().into_bytes(),
)
.map_err(|_| err("invalid content length"))?;
}
let request = OutgoingRequest::new(fields);
request
.set_method(&if self.spool.is_some() {
Method::Post
} else {
Method::Get
})
.map_err(|_| err("invalid HTTP method"))?;
let scheme = if target.scheme() == "https" {
Scheme::Https
} else {
Scheme::Http
};
request
.set_scheme(Some(&scheme))
.map_err(|_| err("invalid HTTP scheme"))?;
let authority = &target[url::Position::BeforeHost..url::Position::AfterPort];
request
.set_authority(Some(authority))
.map_err(|_| err("invalid HTTP authority"))?;
request
.set_path_with_query(Some(
&target[url::Position::BeforePath..url::Position::AfterQuery],
))
.map_err(|_| err("invalid HTTP path"))?;
let body = request
.body()
.map_err(|_| err("could not open HTTP request body"))?;
let options = RequestOptions::new();
options
.set_connect_timeout(Some(
self.timeout.map_or(120_000_000_000, |s| s * 1_000_000_000),
))
.map_err(|_| err("connect timeout unsupported"))?;
options
.set_first_byte_timeout(Some(
self.timeout.map_or(120_000_000_000, |s| s * 1_000_000_000),
))
.map_err(|_| err("read timeout unsupported"))?;
options
.set_between_bytes_timeout(Some(
self.timeout.map_or(120_000_000_000, |s| s * 1_000_000_000),
))
.map_err(|_| err("read timeout unsupported"))?;
let future = outgoing_handler::handle(request, Some(options))
.map_err(|_| err("HTTP connection failed"))?;
if let Some(file) = self.spool.as_mut() {
file.rewind()?;
let stream = body.write().map_err(|_| err("could not write HTTP body"))?;
let mut bytes = [0; 4096]; // WASI blocking_write_and_flush accepts at most 4096 bytes
loop {
let n = file.read(&mut bytes)?;
if n == 0 {
break;
}
stream
.blocking_write_and_flush(&bytes[..n])
.map_err(|_| err("HTTP request write failed or timed out"))?;
}
drop(stream);
}
OutgoingBody::finish(body, None).map_err(|_| err("HTTP request finish failed"))?;
future.subscribe().block();
future
.get()
.ok_or_else(|| err("HTTP response not ready"))?
.map_err(|_| err("HTTP response already consumed"))?
.map_err(|code| err(format!("HTTP request failed: {code:?}")))
}
}
pub struct Headers(Shared);
pub struct Response(Shared, Vec<u8>, usize);
pub struct PostBody(Shared);
impl Write for PostBody {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0
.borrow_mut()
.spool
.as_mut()
.ok_or_else(|| err("request closed"))?
.write(bytes)
}
fn flush(&mut self) -> io::Result<()> {
self.0
.borrow_mut()
.spool
.as_mut()
.ok_or_else(|| err("request closed"))?
.flush()
}
}
impl Drop for PostBody {
fn drop(&mut self) {
self.0.borrow_mut().writer_open = false;
}
}
impl Read for Headers {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let mut p = self.0.borrow_mut();
p.start()?;
p.response_headers
.as_mut()
.ok_or_else(|| err("missing headers"))?
.read(buf)
}
}
impl Read for Response {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let available = self.fill_buf()?;
let n = available.len().min(buf.len());
buf[..n].copy_from_slice(&available[..n]);
self.consume(n);
Ok(n)
}
}
impl BufRead for Response {
fn fill_buf(&mut self) -> io::Result<&[u8]> {
if self.2 == self.1.len() {
let mut p = self.0.borrow_mut();
p.start()?;
self.1.resize(16 * 1024, 0);
let n = p
.response
.as_mut()
.ok_or_else(|| err("missing response"))?
.read(&mut self.1)?;
self.1.truncate(n);
self.2 = 0;
}
Ok(&self.1[self.2..])
}
fn consume(&mut self, n: usize) {
self.2 = (self.2 + n).min(self.1.len());
}
}
impl WasiHttp {
fn pending(
&self,
url: &str,
base: &str,
headers: impl IntoIterator<Item = impl AsRef<str>>,
post: bool,
) -> io::Result<Shared> {
let (spool, path) = if post {
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let dir = ".";
let path = std::path::PathBuf::from(format!(
"{dir}/git-http-{}",
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
(
Some(
File::options()
.read(true)
.write(true)
.create_new(true)
.open(&path)?,
),
Some(path),
)
} else {
(None, None)
};
Ok(Rc::new(RefCell::new(Pending {
timeout: self.timeout,
url: url.into(),
base: base.into(),
headers: headers.into_iter().map(|s| s.as_ref().to_owned()).collect(),
spool,
path,
writer_open: post,
response_headers: None,
response: None,
redirect: self.redirect.clone(),
})))
}
}
impl Http for WasiHttp {
type Headers = BufReader<Headers>;
type ResponseBody = Response;
type PostBody = PostBody;
fn get(
&mut self,
url: &str,
base: &str,
headers: impl IntoIterator<Item = impl AsRef<str>>,
) -> gix_error::Result<GetResponse<Self::Headers, Response>> {
let p = self
.pending(url, base, headers, false)
.map_err(|e| e.raise())?;
p.borrow_mut().start().map_err(|e| e.raise())?;
Ok(GetResponse {
headers: BufReader::new(Headers(p.clone())),
body: Response(p, vec![], 0),
})
}
fn post(
&mut self,
url: &str,
base: &str,
headers: impl IntoIterator<Item = impl AsRef<str>>,
_: PostBodyDataKind,
) -> gix_error::Result<PostResponse<Self::Headers, Response, PostBody>> {
let p = self
.pending(url, base, headers, true)
.map_err(|e| e.raise())?;
Ok(PostResponse {
headers: BufReader::new(Headers(p.clone())),
body: Response(p.clone(), vec![], 0),
post_body: PostBody(p),
})
}
fn configure(&mut self, _: &dyn std::any::Any) -> gix_error::Result<()> {
Ok(())
}
fn redirected_base_url(&self) -> Option<String> {
self.redirect.borrow().clone()
}
}
+827
View File
@@ -0,0 +1,827 @@
//! Local Git operations adapted from the v2 gix plugin, without RPC or host imports.
#![allow(dead_code)]
mod branches;
mod changes;
mod checkout;
mod http;
mod merge;
mod network;
mod output;
mod push;
mod rebase;
mod remotes;
mod repo;
mod staging;
mod worktrees;
use gix::bstr::ByteSlice;
use repo::error;
#[derive(Debug)]
pub struct Error {
pub message: String,
}
impl Error {
pub fn new(_: i32, message: String) -> Self {
Self { message }
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(serde::Serialize, serde::Deserialize)]
pub struct HeadInfo {
detached: bool,
unborn: bool,
branch: Option<String>,
id: Option<String>,
}
include!("head.inc");
struct Identity {
name: Option<String>,
email: Option<String>,
}
fn identity(name: Option<String>, email: Option<String>) -> Result<Identity> {
let repo = gix::discover(".").map_err(|e| error("identity", e))?;
let config = repo.config_snapshot();
Ok(Identity {
name: name
.or_else(|| std::env::var("GIT_AUTHOR_NAME").ok())
.or_else(|| config.string("user.name").map(|s| s.to_string())),
email: email
.or_else(|| std::env::var("GIT_AUTHOR_EMAIL").ok())
.or_else(|| config.string("user.email").map(|s| s.to_string())),
})
}
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>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Show {
revision: Option<String>,
},
Clone {
url: String,
path: Option<String>,
},
Fetch {
#[arg(default_value = "origin")]
remote: String,
},
Push {
#[arg(short = 'u', long)]
set_upstream: bool,
#[arg(short = 'f', long)]
force: bool,
#[arg(default_value = "origin")]
remote: String,
refspec: Option<String>,
},
Init {
#[arg(long)]
bare: bool,
#[arg(short = 'b', default_value = "main")]
initial_branch: String,
path: Option<String>,
},
Config {
#[arg(long)]
local: bool,
key: String,
value: Option<String>,
},
RevParse {
#[arg(long)]
show_toplevel: bool,
#[arg(long)]
git_dir: bool,
#[arg(long)]
abbrev_ref: bool,
revision: Option<String>,
},
Status {
#[arg(short, long)]
short: bool,
#[arg(long)]
porcelain: bool,
},
Add {
#[arg(short = 'A', long)]
all: bool,
paths: Vec<String>,
},
Rm {
#[arg(long)]
cached: bool,
paths: Vec<String>,
},
Commit {
#[arg(short = 'm')]
message: String,
#[arg(short, long)]
all: bool,
#[arg(long)]
allow_empty: bool,
},
Log {
#[arg(long)]
oneline: bool,
#[arg(short = 'n', default_value = "50")]
limit: usize,
revision: Option<String>,
},
Diff {
#[arg(long, alias = "staged")]
cached: bool,
#[arg(long)]
stat: bool,
},
Branch {
#[arg(short = 'd')]
delete: bool,
#[arg(short = 'D')]
force_delete: bool,
name: Option<String>,
start: Option<String>,
},
Switch {
#[arg(short = 'c')]
create: bool,
target: String,
},
Checkout {
#[arg(short = 'b')]
create: bool,
target: String,
},
Merge {
#[arg(long)]
ff_only: bool,
#[arg(long)]
no_ff: bool,
#[arg(long)]
no_commit: bool,
#[arg(long)]
abort: bool,
#[arg(short = 'm')]
message: Option<String>,
revision: Option<String>,
},
Rebase {
#[arg(long)]
abort: bool,
#[arg(long = "continue")]
resume: bool,
#[arg(long)]
skip: bool,
upstream: Option<String>,
},
Worktree {
#[command(subcommand)]
command: Worktree,
},
}
#[derive(Subcommand)]
enum Worktree {
List {
#[arg(long)]
porcelain: bool,
},
Add {
#[arg(short = 'b')]
branch: Option<String>,
path: String,
revision: Option<String>,
},
Remove {
#[arg(long)]
force: bool,
path: String,
},
}
fn required(value: Option<String>, what: &str) -> Result<String> {
value.ok_or_else(|| error(what, "argument required"))
}
pub fn run(cli: Cli) -> Result<i32> {
if let Some(dir) = cli.directory {
std::env::set_current_dir(dir).map_err(|e| error("chdir", e))?;
}
if let Command::Init {
bare,
initial_branch,
path,
} = cli.command
{
let path = path.unwrap_or_else(|| ".".into());
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))?;
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 {
let path = path.unwrap_or_else(|| {
url.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or("repo")
.trim_end_matches(".git")
.to_owned()
});
network::clone(&url, &path)?;
return Ok(0);
}
let discovered = gix::discover(".").map_err(|e| error("repository", e))?;
let root = discovered
.workdir()
.unwrap_or(discovered.git_dir())
.to_owned();
std::env::set_current_dir(&root).map_err(|e| error("chdir", e))?;
let path = root.to_string_lossy().into_owned();
let repository = repo::open(&path)?;
match cli.command {
Command::Config { key, value, .. } => {
let file = repository.common_dir().join("config");
if let Some(value) = value {
let mut config = gix::config::File::from_path_no_includes(
file.clone(),
gix::config::Source::Local,
)
.map_err(|e| error("config", e))?;
config
.set_raw_value(&key, value.as_bytes().as_bstr())
.map_err(|e| error("config", e))?;
let mut output = std::fs::File::create(file).map_err(|e| error("config", e))?;
config
.write_to(&mut output)
.map_err(|e| error("config", e))?;
} else if let Some(value) = repository.config_snapshot().string(&key) {
println!("{value}");
} else {
return Ok(1);
}
}
Command::RevParse {
show_toplevel,
git_dir,
abbrev_ref,
revision,
} => {
if show_toplevel {
println!("{}", root.display());
} else if git_dir {
println!(".git");
} else if abbrev_ref {
println!(
"{}",
head_info(&repository)?
.branch
.unwrap_or_else(|| "HEAD".into())
.trim_start_matches("refs/heads/")
);
} else {
println!(
"{}",
repository
.rev_parse_single(required(revision, "revision")?.as_str())
.map_err(|e| error("revision", e))?
);
}
}
Command::Add { all, mut paths } => {
let all = all || paths.iter().any(|p| p == ".");
paths.retain(|p| p != ".");
staging::add(
&repository,
staging::AddParams {
repo: path,
paths,
all: Some(all),
},
)?;
}
Command::Rm { cached, paths } => {
let result = staging::rm(
&repository,
staging::RmParams {
repo: path,
paths,
keep_file: Some(cached),
},
)?;
for path in result.paths {
println!("rm '{path}'");
}
}
Command::Commit {
message,
all,
allow_empty,
} => {
let before = head_info(&repository)?;
if !allow_empty && merge::pending_parent(&repository)?.is_none() {
let status = changes::status(&repository)?;
if status.staged.is_empty()
&& (!all || status.unstaged.is_empty())
&& status.conflicted.is_empty()
{
output::long_status(&status);
return Ok(1);
}
}
let result = staging::commit(
&repository,
staging::CommitParams {
repo: path.clone(),
message: format!("{message}\n"),
author_name: None,
author_email: None,
all: Some(all),
amend: None,
allow_empty: Some(allow_empty),
},
)?;
println!(
"[{}{} {}] {}",
before
.branch
.unwrap_or_else(|| "HEAD".into())
.trim_start_matches("refs/heads/"),
if before.unborn { " (root-commit)" } else { "" },
&result.id[..7],
message
);
let old = match before.id {
Some(id) => id,
None => repository
.write_object(&gix::objs::Tree { entries: vec![] })
.map_err(|e| error("commit summary", e))?
.to_string(),
};
output::commit_stat(&changes::diff(changes::DiffParams {
repo: path,
from: Some(old),
to: Some(result.id),
staged: None,
path: None,
context: None,
})?);
}
Command::Status { short, porcelain } => {
let mut status = changes::status(&repository)?;
let index = repository
.index_or_empty()
.map_err(|e| error("status index", e))?;
let tracked: Vec<_> = index
.entries()
.iter()
.map(|e| e.path(&index).to_str_lossy().into_owned())
.collect();
let mut untracked = std::collections::BTreeSet::new();
for path in status.untracked {
let collapsed = path
.match_indices('/')
.map(|(i, _)| &path[..=i])
.find(|prefix| !tracked.iter().any(|t| t.starts_with(*prefix)))
.unwrap_or(&path);
untracked.insert(collapsed.to_owned());
}
status.untracked = untracked.into_iter().collect();
if !short && !porcelain {
output::long_status(&status);
return Ok(0);
}
let mut rows = std::collections::BTreeMap::new();
for c in status.staged {
rows.entry(c.path).or_insert([' '; 2])[0] = code(&c.status);
}
for c in status.unstaged {
rows.entry(c.path).or_insert([' '; 2])[1] = code(&c.status);
}
for p in status.untracked {
rows.insert(p, ['?', '?']);
}
for p in status.conflicted {
rows.insert(p, ['U', 'U']);
}
for (p, c) in rows {
println!("{}{} {p}", c[0], c[1]);
}
}
Command::Diff { cached, stat } => {
let diffs = changes::diff(changes::DiffParams {
repo: path,
from: None,
to: None,
staged: Some(cached),
path: None,
context: None,
})?;
if stat {
output::stat(&diffs, false);
} else {
for diff in diffs {
print!("{}", diff.patch);
}
}
}
Command::Log {
oneline,
limit,
revision,
} => {
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 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
.map_err(|e| error("log", e))?
.object()
.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 {
println!(
"{} {}",
&c.id.to_string()[..7],
message.lines().next().unwrap_or("")
);
} else {
if !first {
println!();
}
output::commit(&c)?;
first = false;
}
}
}
Command::Branch {
delete,
force_delete,
name,
start,
} => {
if let Some(name) = name {
if delete || force_delete {
branches::delete(
&repository,
branches::BranchDeleteParams {
repo: path,
name,
force: Some(force_delete),
},
)?;
} else {
branches::create(
&repository,
branches::BranchCreateParams {
repo: path,
name,
from: start,
checkout: Some(false),
},
)?;
}
} else {
let head = head_info(&repository)?;
for branch in repository
.references()
.map_err(|e| error("branch", e))?
.local_branches()
.map_err(|e| error("branch", e))?
{
let branch = branch.map_err(|e| error("branch", e))?;
println!(
"{} {}",
if head.branch.as_deref()
== Some(branch.name().as_bstr().to_str_lossy().as_ref())
{
"*"
} else {
" "
},
branch.name().shorten()
);
}
}
}
Command::Switch { create, target } | Command::Checkout { create, target } => {
branches::checkout(
&repository,
branches::CheckoutParams {
repo: path,
target,
create: Some(create),
force: None,
},
)?;
}
Command::Merge {
ff_only,
no_ff,
no_commit,
abort,
message,
revision,
} => {
let old = head_info(&repository)?.id;
let message =
message.or_else(|| revision.as_ref().map(|r| format!("Merge branch '{r}'\n")));
let r = if abort {
merge::abort(&repository)?
} else {
merge::merge(
&repository,
merge::MergeParams {
repo: path.clone(),
rev: required(revision, "merge")?,
message,
ff: Some(if ff_only {
merge::FastForward::Only
} else if no_ff {
merge::FastForward::Never
} else {
merge::FastForward::Allow
}),
no_commit: Some(no_commit),
},
)?
};
if r.already_up_to_date {
println!("Already up to date.");
}
output::conflicts(&r.conflicts, true);
if !r.conflicts.is_empty() {
return Ok(1);
}
if !abort && !r.already_up_to_date && !r.pending {
let new = head_info(&repo::open(&path)?)?.id.unwrap();
if let Some(old) = old {
if r.fast_forward {
println!("Updating {}..{}\nFast-forward", &old[..7], &new[..7]);
} else {
println!("Merge made by the 'ort' strategy.");
}
output::stat(
&changes::diff(changes::DiffParams {
repo: path,
from: Some(old),
to: Some(new),
staged: None,
path: None,
context: None,
})?,
true,
);
}
}
}
Command::Rebase {
abort,
resume,
skip,
upstream,
} => {
let r = if abort {
rebase::abort(&repository)?
} else if resume || skip {
rebase::resume(&repository, skip)?
} else {
rebase::start(
&repository,
rebase::RebaseParams {
repo: path.clone(),
onto: Some(required(upstream, "rebase")?),
branch: None,
},
)?
};
if let Some(id) = &r.resolved {
let commit = repository
.find_commit(
gix::ObjectId::from_hex(id.as_bytes())
.map_err(|e| error("rebase output", e))?,
)
.map_err(|e| error("rebase output", e))?;
let decoded = commit.decode().map_err(|e| error("rebase output", e))?;
println!(
"[detached HEAD {}] {}",
&id[..7],
decoded.message.to_str_lossy().lines().next().unwrap_or("")
);
output::commit_stat(&changes::diff(changes::DiffParams {
repo: path,
from: Some(decoded.parents[0].to_string()),
to: Some(id.clone()),
staged: None,
path: None,
context: None,
})?);
}
output::conflicts(&r.conflicts, false);
if !r.conflicts.is_empty() {
return Ok(1);
}
}
Command::Worktree { command } => match command {
Worktree::List { porcelain } => {
let common = repository
.common_dir()
.canonicalize()
.map_err(|e| error("worktree list", e))?;
let mut paths = vec![
common
.parent()
.ok_or_else(|| error("worktree list", "no worktree"))?
.to_owned(),
];
if let Ok(admins) = std::fs::read_dir(common.join("worktrees")) {
let mut linked = Vec::new();
for admin in admins {
let admin = admin.map_err(|e| error("worktree list", e))?;
let gitfile = std::fs::read_to_string(admin.path().join("gitdir"))
.map_err(|e| error("worktree list", e))?;
if let Some(parent) = std::path::Path::new(gitfile.trim()).parent() {
linked.push(parent.to_owned());
}
}
linked.sort();
paths.extend(linked);
}
let width = paths
.iter()
.map(|p| p.to_string_lossy().len())
.max()
.unwrap_or(0)
+ 2;
for p in paths {
let linked = repo::open(&p.to_string_lossy())?;
let head = head_info(&linked)?;
let id = head.id.unwrap_or_else(|| "0".repeat(40));
if porcelain {
println!("worktree {}\nHEAD {}", p.display(), id);
if let Some(branch) = head.branch {
println!("branch {branch}");
} else {
println!("detached");
}
println!();
} else {
let branch = head
.branch
.map(|b| format!("[{}]", b.trim_start_matches("refs/heads/")))
.unwrap_or_else(|| "(detached HEAD)".into());
println!("{:width$}{} {}", p.display().to_string(), &id[..7], branch);
}
}
}
Worktree::Add {
branch,
path: target,
revision,
} => {
let branch = branch.unwrap_or_else(|| {
std::path::Path::new(&target)
.file_name()
.unwrap()
.to_string_lossy()
.into_owned()
});
let info = worktrees::add(
&repository,
worktrees::AddParams {
repo: path,
path: std::path::absolute(target)
.map_err(|e| error("worktree path", e))?
.to_string_lossy()
.into_owned(),
branch,
from: revision,
adopt: None,
},
)?;
let linked = repo::open(&info.path)?;
let commit = linked
.head_commit()
.map_err(|e| error("worktree HEAD", e))?;
let decoded = commit.decode().map_err(|e| error("worktree HEAD", e))?;
println!(
"HEAD is now at {} {}",
&commit.id.to_string()[..7],
decoded.message.to_str_lossy().lines().next().unwrap_or("")
);
}
Worktree::Remove { force, path } => {
let target = std::path::absolute(path)
.map_err(|e| error("worktree remove", e))?
.to_string_lossy()
.into_owned();
worktrees::remove(
&repo::open(&target)?,
worktrees::RemoveParams {
repo: target,
force: Some(force),
},
)?;
}
},
Command::Show { revision } => {
let object = repository
.rev_parse_single(revision.as_deref().unwrap_or("HEAD"))
.map_err(|e| error("show", e))?
.object()
.map_err(|e| error("show", e))?;
if object.kind == gix::objs::Kind::Blob {
use std::io::Write;
std::io::stdout()
.write_all(&object.data)
.map_err(|e| error("show", e))?;
} else {
let commit = object.peel_to_commit().map_err(|e| error("show", e))?;
output::commit(&commit)?;
println!();
let old = match commit.parent_ids().next() {
Some(id) => id.to_string(),
None => repository
.write_object(&gix::objs::Tree { entries: vec![] })
.map_err(|e| error("show", e))?
.to_string(),
};
for diff in changes::diff(changes::DiffParams {
repo: path,
from: Some(old),
to: Some(commit.id.to_string()),
staged: None,
path: None,
context: None,
})? {
print!("{}", diff.patch);
}
}
}
Command::Fetch { remote } => network::fetch(&path, &remote)?,
Command::Push {
set_upstream,
force,
remote,
refspec,
} => {
push::push(push::PushParams {
repo: path,
remote: Some(remote),
branch: None,
refspec,
force: Some(force),
set_upstream: Some(set_upstream),
delete: None,
})?;
}
Command::Init { .. } | Command::Clone { .. } => unreachable!(),
}
Ok(0)
}
fn code(k: &changes::Kind) -> char {
match k {
changes::Kind::Added => 'A',
changes::Kind::Modified => 'M',
changes::Kind::Deleted => 'D',
changes::Kind::Renamed => 'R',
}
}
+39
View File
@@ -0,0 +1,39 @@
//! Parse Git CLI arguments and return Git-compatible usage and operation exit codes.
use clap::{Parser, error::ErrorKind};
fn main() {
let cli = match git::Cli::try_parse() {
Ok(cli) => cli,
Err(error) => {
let code = if !error.use_stderr() {
0
} else {
match error.kind() {
ErrorKind::InvalidSubcommand | ErrorKind::MissingSubcommand => 1,
_ => 129,
}
};
let _ = error.print();
exit(code);
}
};
let code = match git::run(cli) {
Ok(code) => code,
Err(error) => {
eprintln!("fatal: {error}");
128
}
};
exit(code);
}
fn exit(code: i32) -> ! {
#[cfg(target_os = "wasi")]
{
use std::io::Write;
let _ = std::io::stdout().flush();
let _ = std::io::stderr().flush();
wasi::cli::exit::exit_with_code(code as u8);
unreachable!("WASI exit returned");
}
#[cfg(not(target_os = "wasi"))]
std::process::exit(code)
}
+407
View File
@@ -0,0 +1,407 @@
//! Merge workflow around gix's recursive commit/tree merge. Callers hold .git lock.
use crate::Result;
use crate::{branches, checkout, repo::error, staging};
use gix::bstr::ByteSlice;
use std::{collections::BTreeSet, fs};
/// Fast-forward policy.
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FastForward {
/// Advance HEAD directly when possible, otherwise perform a three-way merge.
Allow,
/// Refuse divergent histories instead of making a merge commit.
Only,
/// Make a two-parent commit even when a fast-forward is possible.
Never,
}
/// Merge a single commit into HEAD.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct MergeParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Revision to merge.
pub rev: String,
/// Merge message; defaults to Merge '<rev>'.
pub message: Option<String>,
/// Fast-forward policy, default allow.
pub ff: Option<FastForward>,
/// Stop before creating a merge commit (does not stop a fast-forward).
pub no_commit: Option<bool>,
}
/// Resume a merge after git.add resolves every conflict.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ContinueParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Override MERGE_MSG.
pub message: Option<String>,
}
/// Merge outcome.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct MergeResult {
/// HEAD after the operation.
pub head: crate::HeadInfo,
/// True when HEAD was advanced without creating a merge commit.
pub fast_forward: bool,
/// True when the requested commit was already reachable.
pub already_up_to_date: bool,
/// True when merge_continue or merge_abort is needed.
pub pending: bool,
/// Sorted unresolved index paths; stage 1=base, 2=ours, 3=theirs.
pub conflicts: Vec<String>,
/// Checkout feature limitations.
pub warnings: Vec<String>,
}
fn result(
repo: &gix::Repository,
fast_forward: bool,
already: bool,
pending: bool,
conflicts: Vec<String>,
warnings: Vec<String>,
) -> Result<MergeResult> {
Ok(MergeResult {
head: crate::head_info(repo)?,
fast_forward,
already_up_to_date: already,
pending,
conflicts,
warnings,
})
}
pub fn ensure_idle(repo: &gix::Repository) -> Result<()> {
for name in [
"MERGE_HEAD",
"CHERRY_PICK_HEAD",
"REVERT_HEAD",
"rebase-merge",
"rebase-apply",
] {
if repo.git_dir().join(name).exists() {
return Err(error(
"merge",
format!("{name} exists; finish or abort the current operation first"),
));
}
}
Ok(())
}
fn tree(repo: &gix::Repository, id: gix::ObjectId) -> Result<gix::ObjectId> {
Ok(repo
.find_commit(id)
.map_err(|e| error("merge commit", e))?
.tree_id()
.map_err(|e| error("merge tree", e))?
.detach())
}
fn reachable(repo: &gix::Repository, tip: gix::ObjectId, ancestor: gix::ObjectId) -> Result<bool> {
let walk = repo
.find_commit(tip)
.map_err(|e| error("merge ancestry", e))?
.ancestors()
.use_commit_graph(false)
.all()
.map_err(|e| error("merge ancestry", e))?;
for entry in walk {
if entry.map_err(|e| error("merge ancestry", e))?.id == ancestor {
return Ok(true);
}
}
Ok(false)
}
pub(crate) fn clean(repo: &gix::Repository) -> Result<()> {
check_index_lock(repo)?;
let s = crate::changes::status(repo)?;
let paths: BTreeSet<_> = s
.staged
.into_iter()
.chain(s.unstaged)
.flat_map(|c| std::iter::once(c.path).chain(c.old_path))
.chain(s.conflicted)
.collect();
if !paths.is_empty() {
return Err(error(
"merge dirty worktree/index",
paths.into_iter().collect::<Vec<_>>().join(", "),
));
}
// Untracked files may remain; the checkout engine preflights all obstructions, including ignored files.
Ok(())
}
fn validate_message(message: &str) -> Result<()> {
if message.trim().is_empty() || message.contains('\0') {
return Err(error(
"merge",
"message must be nonblank and contain no NUL",
));
}
Ok(())
}
fn save(
repo: &gix::Repository,
ours: gix::ObjectId,
theirs: gix::ObjectId,
message: &str,
ff: FastForward,
) -> Result<()> {
for (name, bytes) in [
("ORIG_HEAD", format!("{ours}\n")),
("MERGE_MSG", message.to_owned()),
(
"MERGE_MODE",
if ff == FastForward::Never {
"no-ff".into()
} else {
String::new()
},
),
("MERGE_HEAD", format!("{theirs}\n")),
] {
fs::write(repo.git_dir().join(name), bytes).map_err(|e| error("merge state", e))?;
}
Ok(())
}
pub fn clear(repo: &gix::Repository) -> Result<()> {
for name in ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"] {
match fs::remove_file(repo.git_dir().join(name)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(error("merge cleanup", e)),
}
}
Ok(())
}
pub fn pending_parent(repo: &gix::Repository) -> Result<Option<gix::ObjectId>> {
let bytes = match fs::read_to_string(repo.git_dir().join("MERGE_HEAD")) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(error("merge state", e)),
};
let ids: Vec<_> = bytes.lines().collect();
if ids.len() != 1 {
return Err(error(
"merge state",
"expected one MERGE_HEAD; octopus merges unsupported",
));
}
let id = gix::ObjectId::from_hex(ids[0].as_bytes()).map_err(|e| error("MERGE_HEAD", e))?;
repo.find_commit(id).map_err(|e| error("MERGE_HEAD", e))?;
Ok(Some(id))
}
fn advance(
repo: &gix::Repository,
old: Option<gix::ObjectId>,
new: gix::ObjectId,
) -> Result<Vec<String>> {
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
// Identity checked by caller before any worktree mutation.
branches::edit_refs(
repo,
vec![RefEdit {
name: "HEAD".try_into().map_err(|e| error("merge HEAD", e))?,
deref: true,
change: Change::Update {
expected: old
.map(|id| PreviousValue::MustExistAndMatch(Target::Object(id)))
.unwrap_or(PreviousValue::MustNotExist),
new: Target::Object(new),
log: Default::default(),
},
}],
)
}
pub fn merge(repo: &gix::Repository, p: MergeParams) -> Result<MergeResult> {
// In-memory policy only; never rewrite the person's .git/config. No mmap,
// renormalization/eol filters, or external merge-driver processes on WASI.
let local = merge_repository(repo)?;
let repo = &local;
ensure_idle(repo)?;
clean(repo)?;
let theirs = branches::resolve(repo, &p.rev)?;
merge_inner(repo, p, theirs)
}
/// Object-only merge policy shared with the rebase sequencer. Never rewrites config.
pub(crate) fn merge_repository(repo: &gix::Repository) -> Result<gix::Repository> {
let mut local = repo.clone();
{
let mut config = local.config_snapshot_mut();
config
.set_raw_value("core.commitGraph", "false")
.map_err(|e| error("merge config", e))?;
config
.set_raw_value("merge.renormalize", "false")
.map_err(|e| error("merge config", e))?;
config
.set_raw_value("merge.default", "text")
.map_err(|e| error("merge config", e))?;
let names: Vec<_> = config
.sections_by_name("merge")
.into_iter()
.flatten()
.filter_map(|s| s.header().subsection_name().map(ToOwned::to_owned))
.collect();
for name in names {
while config.remove_section("merge", name.as_bstr()).is_some() {}
}
config.commit().map_err(|e| error("merge config", e))?;
}
Ok(local)
}
fn merge_inner(
repo: &gix::Repository,
p: MergeParams,
theirs: gix::ObjectId,
) -> Result<MergeResult> {
let ours = repo
.head()
.map_err(|e| error("merge HEAD", e))?
.id()
.map(|id| id.detach());
let ff = p.ff.unwrap_or(FastForward::Allow);
let their_tree = tree(repo, theirs)?;
if let Some(ours) = ours {
if reachable(repo, ours, theirs)? {
return result(repo, false, true, false, vec![], vec![]);
}
}
let can_ff = match ours {
Some(ours) => reachable(repo, theirs, ours)?,
None => true,
};
if ff == FastForward::Only && !can_ff {
return Err(error(
"merge ff-only",
"not possible: histories have diverged",
));
}
if can_ff && (ff != FastForward::Never || ours.is_none()) {
repo.committer()
.transpose()
.map_err(|e| error("merge reflog identity", e))?;
let mut warnings = checkout::write_tree_to_worktree(
repo,
ours.map(|id| tree(repo, id)).transpose()?,
their_tree,
false,
)?;
if let Some(id) = ours {
fs::write(repo.git_dir().join("ORIG_HEAD"), format!("{id}\n"))
.map_err(|e| error("merge ORIG_HEAD", e))?;
}
warnings.extend(advance(repo, ours, theirs)?);
return result(repo, true, false, false, vec![], warnings);
}
let ours = ours.ok_or_else(|| error("merge", "unborn HEAD"))?;
let message = p
.message
.unwrap_or_else(|| format!("Merge '{}'\n", p.rev.replace(['\n', '\r'], " ")));
validate_message(&message)?;
// Resolve identity before changing files. no_commit/conflicts may be continued after configuring identity.
if !p.no_commit.unwrap_or(false) {
staging::signature(None, None)?;
}
let labels = gix::merge::blob::builtin_driver::text::Labels {
ancestor: None,
current: Some("ours".into()),
other: Some("theirs".into()),
};
let mut outcome = repo
.merge_commits(
ours,
theirs,
labels,
repo.tree_merge_options()
.map_err(|e| error("merge options", e))?
.into(),
)
.map_err(|e| error("merge three-way", e))?
.tree_merge;
let unresolved = outcome.has_unresolved_conflicts(Default::default());
let merged = outcome
.tree
.write()
.map_err(|e| error("merge tree write", e))?
.detach();
let mut state = gix::index::State::from_tree(&merged, &repo.objects, Default::default())
.map_err(|e| error("merge index", e))?;
outcome.index_changed_after_applying_conflicts(
&mut state,
Default::default(),
gix::merge::tree::apply_index_entries::RemovalMode::Prune,
);
state.sort_entries();
let conflicts: BTreeSet<_> = state
.entries()
.iter()
.filter(|e| e.stage_raw() != 0)
.map(|e| e.path(&state).to_str_lossy().into_owned())
.collect();
if unresolved && conflicts.is_empty() {
return Err(error(
"merge",
"gix reported a conflict without index stages; cannot safely publish this merge",
));
}
// The result tree contains gix-generated marker blobs and evasively renamed paths.
let warnings = checkout::write_tree_to_worktree(repo, Some(tree(repo, ours)?), merged, false)?;
save(repo, ours, theirs, &message, ff)?;
staging::publish(repo, state)?;
if !conflicts.is_empty() || p.no_commit.unwrap_or(false) {
return result(
repo,
false,
false,
true,
conflicts.into_iter().collect(),
warnings,
);
}
continue_merge(
repo,
ContinueParams {
repo: p.repo,
message: Some(message),
},
)?;
result(repo, false, false, false, vec![], warnings)
}
pub fn continue_merge(repo: &gix::Repository, p: ContinueParams) -> Result<staging::CommitResult> {
pending_parent(repo)?.ok_or_else(|| error("merge continue", "no merge in progress"))?;
let message = match p.message {
Some(m) => m,
None => fs::read_to_string(repo.git_dir().join("MERGE_MSG"))
.map_err(|e| error("MERGE_MSG", e))?,
};
staging::commit(
repo,
staging::CommitParams {
repo: p.repo,
message,
author_name: None,
author_email: None,
all: None,
amend: None,
allow_empty: Some(true),
},
)
}
fn check_index_lock(repo: &gix::Repository) -> Result<()> {
if repo.git_dir().join("index.lock").exists() {
return Err(error("merge index", "index.lock already exists"));
}
Ok(())
}
pub fn abort(repo: &gix::Repository) -> Result<MergeResult> {
check_index_lock(repo)?;
if !repo.git_dir().join("MERGE_HEAD").exists() {
return Err(error("merge abort", "no merge in progress"));
}
let head = branches::resolve(repo, "HEAD")?;
let warnings = checkout::abort_to_tree(repo, tree(repo, head)?)?;
clear(repo)?;
result(repo, false, false, false, vec![], warnings)
}
+241
View File
@@ -0,0 +1,241 @@
//! Anonymous HTTP and HTTPS clone/fetch through standard wasi:http.
use crate::{
Result,
http::{WasiHttp, clean_url},
remotes,
repo::{self, error},
};
use gix::bstr::ByteSlice;
use std::{collections::BTreeSet, sync::atomic::AtomicBool};
struct FetchResult {
remote: String,
advertised_refs: usize,
received_pack: bool,
pruned: Vec<String>,
copied_objects: Option<usize>,
updated_refs: Option<Vec<String>>,
}
pub(crate) fn fetch(path: &str, remote: &str) -> Result<()> {
let mut repo = repo::open(path)?;
repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
perform(&repo, remote, None, false)?;
Ok(())
}
pub(crate) fn clone(url: &str, path: &str) -> Result<()> {
clean_url(url).map_err(|e| error("clone URL", e))?;
let path = std::path::absolute(path).map_err(|e| error("clone path", e))?;
std::fs::create_dir(&path).map_err(|e| error("clone destination", e))?;
gix::init(&path).map_err(|e| error("clone init", e))?;
let name = path.to_string_lossy();
let repo = repo::open(&name)?;
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 branch = fetched
.default_branch
.ok_or_else(|| error("clone", "server HEAD does not name a branch"))?;
if !fetched.empty {
crate::branches::create(
&repo,
crate::branches::BranchCreateParams {
repo: name.to_string(),
name: branch.clone(),
from: Some(format!("refs/remotes/origin/{branch}")),
checkout: Some(true),
},
)?;
}
remotes::upstream(&repo, &branch)?;
Ok(())
}
struct Fetched {
summary: FetchResult,
default_branch: Option<String>,
heads: BTreeSet<String>,
empty: bool,
}
fn perform(
repo: &gix::Repository,
remote_name: &str,
depth: Option<u32>,
prune: bool,
) -> Result<Fetched> {
remotes::validate_name(remote_name)?;
let remote = repo
.find_remote(remote_name)
.map_err(|_| error("fetch", "configured remote not found"))?;
let url = remote
.url(gix::remote::Direction::Fetch)
.ok_or_else(|| error("fetch", "remote has no fetch URL"))?
.to_bstring()
.to_str_lossy()
.into_owned();
clean_url(&url).map_err(|e| error("fetch URL", e))?;
if prune {
let expected = format!("+refs/heads/*:refs/remotes/{remote_name}/*");
let specs = remote.refspecs(gix::remote::Direction::Fetch);
if specs.len() != 1 || specs[0].to_ref().to_bstring() != expected.as_bytes() {
return Err(error(
"prune",
"prune requires the standard +refs/heads/*:refs/remotes/<remote>/* refspec",
));
}
}
let transport: Box<dyn gix_transport::client::blocking_io::Transport> = {
let transport_url = gix::url::parse(url.as_bytes().as_bstr())
.map_err(|_| error("fetch", "invalid HTTPS URL"))?;
Box::new(
gix_transport::client::blocking_io::http::Transport::new_http(
WasiHttp::default(),
transport_url,
gix_transport::Protocol::V2,
false,
),
)
};
let mut options = gix::remote::ref_map::Options::default();
options.extra_refspecs.push(
gix::refspec::parse("HEAD".into(), gix::refspec::parse::Operation::Fetch)
.map_err(|e| error("fetch HEAD", e))?
.to_owned(),
);
let mut prepared = remote
.to_connection_with_transport(transport)
.prepare_fetch(gix::progress::Discard, options)
.map_err(|e| error("fetch handshake", format!("{e:#}")))?;
let refs = &prepared.ref_map().remote_refs;
let mut heads = BTreeSet::new();
let mut default_branch = None;
for reference in refs {
let (name, _, _) = reference.unpack();
if let Some(branch) = name.to_str_lossy().strip_prefix("refs/heads/") {
heads.insert(branch.to_owned());
}
match reference {
gix::protocol::handshake::Ref::Symbolic {
full_ref_name,
target,
..
}
| gix::protocol::handshake::Ref::Unborn {
full_ref_name,
target,
} if full_ref_name.as_slice() == b"HEAD" => {
default_branch = target
.to_str_lossy()
.strip_prefix("refs/heads/")
.map(str::to_owned);
}
_ => {}
}
}
let advertised_refs = refs.len();
let empty = heads.is_empty() && refs.iter().all(|r| r.unpack().1.is_none());
if let Some(depth) = depth {
prepared = prepared.with_shallow(gix::remote::fetch::Shallow::DepthAtRemote(
depth
.try_into()
.map_err(|_| error("fetch", "depth must be positive"))?,
));
}
let outcome = prepared
.receive(gix::progress::Discard, &AtomicBool::new(false))
.map_err(|e| error("fetch", format!("{e:#}")))?;
let received_pack = matches!(outcome.status, gix::remote::fetch::Status::Change { .. });
let updates = match &outcome.status {
gix::remote::fetch::Status::Change { update_refs, .. }
| gix::remote::fetch::Status::NoPackReceived { update_refs, .. } => update_refs,
};
use gix::remote::fetch::refs::update::Mode;
for update in &updates.updates {
if !matches!(
update.mode,
Mode::NoChangeNeeded
| Mode::FastForward
| Mode::Forced
| Mode::New
| Mode::ImplicitTagNotSentByRemote
) {
return Err(error(
"fetch refs (other refs may already be updated)",
&update.mode,
));
}
}
if let Some(branch) = default_branch.as_deref().filter(|b| heads.contains(*b)) {
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
repo.edit_references_as(
[RefEdit {
name: format!("refs/remotes/{remote_name}/HEAD")
.as_str()
.try_into()
.map_err(|e| error("remote HEAD", e))?,
deref: false,
change: Change::Update {
log: Default::default(),
expected: PreviousValue::Any,
new: Target::Symbolic(
format!("refs/remotes/{remote_name}/{branch}")
.as_str()
.try_into()
.map_err(|e| error("remote HEAD target", e))?,
),
},
}],
None,
)
.map_err(|e| error("remote HEAD", e))?;
}
let pruned = if prune {
prune_refs(repo, remote_name, &heads)?
} else {
vec![]
};
Ok(Fetched {
summary: FetchResult {
remote: remote_name.into(),
advertised_refs,
received_pack,
pruned,
copied_objects: None,
updated_refs: None,
},
default_branch,
heads,
empty,
})
}
/// Delete only stale refs in this remote's namespace. Symbolic HEAD is not a branch.
pub fn prune_refs(
repo: &gix::Repository,
remote: &str,
heads: &BTreeSet<String>,
) -> Result<Vec<String>> {
remotes::validate_name(remote)?;
let prefix = format!("refs/remotes/{remote}/");
let refs = repo.references().map_err(|e| error("prune refs", e))?;
let mut stale = Vec::new();
for reference in refs
.prefixed(prefix.as_str())
.map_err(|e| error("prune refs", e))?
{
let reference = reference.map_err(|e| error("prune ref", e))?;
let name = reference.name().as_bstr().to_str_lossy().into_owned();
if let Some(short) = name.strip_prefix(&prefix) {
if short != "HEAD" && !heads.contains(short) {
stale.push(name);
}
}
}
for name in &stale {
repo.find_reference(name.as_str())
.map_err(|e| error("prune ref", e))?
.delete()
.map_err(|e| error("prune delete", e))?;
}
Ok(stale)
}
+177
View File
@@ -0,0 +1,177 @@
//! Git's text summaries for small, textual diffs.
use crate::changes::{FileDiff, Kind, Status};
use gix::bstr::ByteSlice;
pub(crate) fn commit(commit: &gix::Commit<'_>) -> crate::Result<()> {
let decoded = commit
.decode()
.map_err(|e| crate::repo::error("commit output", e))?;
println!("commit {}", commit.id);
if decoded.parents.len() > 1 {
println!(
"Merge: {}",
decoded
.parents
.iter()
.map(|id| id.to_string()[..7].to_owned())
.collect::<Vec<_>>()
.join(" ")
);
}
let author = commit
.author()
.map_err(|e| crate::repo::error("commit author", e))?;
let time = author
.time()
.map_err(|e| crate::repo::error("commit date", e))?;
println!(
"Author: {} <{}>\nDate: {}\n\n {}",
author.name,
author.email,
time.format_or_unix(gix::date::time::format::DEFAULT),
decoded
.message
.to_str_lossy()
.trim_end()
.replace('\n', "\n ")
);
Ok(())
}
pub(crate) fn long_status(s: &Status) {
let branch = s
.head
.branch
.as_deref()
.unwrap_or("HEAD")
.trim_start_matches("refs/heads/");
println!("On branch {branch}");
if s.head.unborn {
println!("\nNo commits yet\n");
}
if !s.staged.is_empty() {
println!("Changes to be committed:");
println!(
" (use \"git {} <file>...\" to unstage)",
if s.head.unborn {
"rm --cached"
} else {
"restore --staged"
}
);
for c in &s.staged {
println!("\t{:12}{}", label(&c.status), c.path);
}
println!();
}
if !s.unstaged.is_empty() {
println!("Changes not staged for commit:");
println!(" (use \"git add <file>...\" to update what will be committed)");
println!(" (use \"git restore <file>...\" to discard changes in working directory)");
for c in &s.unstaged {
println!("\t{:12}{}", label(&c.status), c.path);
}
println!();
}
if !s.untracked.is_empty() {
println!(
"Untracked files:\n (use \"git add <file>...\" to include in what will be committed)"
);
for path in &s.untracked {
println!("\t{path}");
}
println!();
}
if s.staged.is_empty() {
if !s.unstaged.is_empty() {
println!("no changes added to commit (use \"git add\" and/or \"git commit -a\")");
} else if !s.untracked.is_empty() {
println!(
"nothing added to commit but untracked files present (use \"git add\" to track)"
);
} else if s.head.unborn {
println!("nothing to commit (create/copy files and use \"git add\" to track)");
} else {
println!("nothing to commit, working tree clean");
}
}
}
fn label(kind: &Kind) -> &'static str {
match kind {
Kind::Added => "new file:",
Kind::Modified => "modified:",
Kind::Deleted => "deleted:",
Kind::Renamed => "renamed:",
}
}
pub(crate) fn stat(diffs: &[FileDiff], summary: bool) {
summarize(diffs, true, summary);
}
pub(crate) fn commit_stat(diffs: &[FileDiff]) {
summarize(diffs, false, true);
}
fn summarize(diffs: &[FileDiff], names: bool, summary: bool) {
let width = diffs.iter().map(|d| d.path.len()).max().unwrap_or(0);
let mut insertions = 0;
let mut deletions = 0;
for diff in diffs {
let added = diff
.patch
.lines()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.count();
let removed = diff
.patch
.lines()
.filter(|l| l.starts_with('-') && !l.starts_with("---"))
.count();
insertions += added;
deletions += removed;
if names {
println!(
" {:width$} | {} {}{}",
diff.path,
added + removed,
"+".repeat(added),
"-".repeat(removed)
);
}
}
if diffs.is_empty() {
return;
}
print!(
" {} file{} changed",
diffs.len(),
if diffs.len() == 1 { "" } else { "s" }
);
if insertions > 0 {
print!(
", {insertions} insertion{}(+)",
if insertions == 1 { "" } else { "s" }
);
}
if deletions > 0 {
print!(
", {deletions} deletion{}(-)",
if deletions == 1 { "" } else { "s" }
);
}
println!();
if summary {
for d in diffs {
match d.status {
Kind::Added => println!(" create mode 100644 {}", d.path),
Kind::Deleted => println!(" delete mode 100644 {}", d.path),
_ => (),
}
}
}
}
pub(crate) fn conflicts(paths: &[String], merge: bool) {
for path in paths {
println!("Auto-merging {path}");
println!("CONFLICT (content): Merge conflict in {path}");
}
if merge && !paths.is_empty() {
println!("Automatic merge failed; fix conflicts and then commit the result.");
}
}
+672
View File
@@ -0,0 +1,672 @@
//! Single-ref receive-pack client over standard wasi:http.
use crate::Result;
use crate::{
http::{WasiHttp, clean_url},
remotes, repo,
repo::error,
};
use gix::bstr::ByteSlice;
use gix_transport::client::blocking_io::http::{Http, PostBodyDataKind};
use std::{
collections::{BTreeMap, BTreeSet},
io::{Read, Write},
};
/// Push one branch or explicit single branch refspec over HTTPS or the SSH exec transport.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PushParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Configured remote, default origin (pushurl takes precedence).
pub remote: Option<String>,
/// Short local branch, default current branch.
pub branch: Option<String>,
/// Single branch refspec: source:destination (including HEAD:destination), source, or :destination to delete. No wildcards.
pub refspec: Option<String>,
/// Allow non-fast-forward replacement. A leading + in refspec also enables this.
pub force: Option<bool>,
/// Set source branch upstream to the remote destination after success.
pub set_upstream: Option<bool>,
/// Delete the destination instead of sending objects (default false).
pub delete: Option<bool>,
}
/// Confirmed remote update; local tracking state is updated only after report-status success.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PushResult {
/// Remote used.
pub remote: String,
/// Full destination ref.
pub reference: String,
/// Advertised old object ID, or null for a new ref.
pub old: Option<String>,
/// New object ID, or null for deletion.
pub new: Option<String>,
/// Whether the server already had the requested value.
pub up_to_date: bool,
/// Number of objects sent (no delta compression).
pub objects: usize,
}
fn branch_ref(name: &str) -> Result<String> {
let full = if name.starts_with("refs/") {
name.to_owned()
} else {
format!("refs/heads/{name}")
};
if name.is_empty() || name.starts_with('-') || !full.starts_with("refs/heads/") {
return Err(error(
"push refspec",
"expected a branch name or refs/heads/<name>; tags and wildcard/multi-ref pushes are not supported",
));
}
let _: gix::refs::FullName = full
.as_str()
.try_into()
.map_err(|e| error("push refspec", e))?;
Ok(full)
}
pub(crate) struct Plan {
pub source: Option<String>,
pub destination: String,
pub new: Option<gix::ObjectId>,
pub force: bool,
}
pub(crate) fn plan(repo: &gix::Repository, p: &PushParams) -> Result<Plan> {
let mut force = p.force.unwrap_or(false);
let (source, destination, deleting) = if let Some(spec) = &p.refspec {
if p.branch.is_some() {
return Err(error("push", "branch and refspec are mutually exclusive"));
}
let spec = if let Some(s) = spec.strip_prefix('+') {
force = true;
s
} else {
spec.as_str()
};
let (src, dst) = spec.split_once(':').unwrap_or((spec, spec));
if dst.is_empty() || dst.contains(':') || spec.contains('*') {
return Err(error(
"push refspec",
"expected one source:destination or :destination",
));
}
let deleting = p.delete.unwrap_or(false) || src.is_empty();
let source = if deleting {
None
} else if src == "HEAD" {
Some(
crate::head_info(repo)?
.branch
.unwrap_or_else(|| "HEAD".into()),
)
} else {
Some(branch_ref(src)?)
};
(source, branch_ref(dst)?, deleting)
} else {
let source = match &p.branch {
Some(branch) => branch_ref(branch)?,
None => crate::head_info(repo)?
.branch
.ok_or_else(|| error("push", "detached HEAD: specify branch or refspec"))?,
};
let deleting = p.delete.unwrap_or(false);
(
if deleting { None } else { Some(source.clone()) },
source,
deleting,
)
};
if deleting && p.set_upstream.unwrap_or(false) {
return Err(error("push", "set_upstream cannot be used with deletion"));
}
if source.as_deref() == Some("HEAD") && p.set_upstream.unwrap_or(false) {
return Err(error(
"push upstream",
"detached HEAD has no branch to set upstream for",
));
}
let new = source
.as_deref()
.map(|src| -> Result<gix::ObjectId> {
let id = if src == "HEAD" {
repo.head_id()
.map_err(|e| error("push source", e))?
.detach()
} else {
let reference = repo
.find_reference(src)
.map_err(|e| error("push source", e))?;
reference
.try_id()
.ok_or_else(|| error("push source", "source must directly name a commit"))?
.detach()
};
repo.find_commit(id)
.map_err(|e| error("push source commit", e))?;
Ok(id)
})
.transpose()?;
Ok(Plan {
source,
destination,
new,
force,
})
}
// pkt-line readers reject truncation and reserved lengths rather than treating missing status as success.
pub(crate) fn pkt(out: &mut impl Write, data: &[u8]) -> Result<()> {
if data.len() > 65516 {
return Err(error("push packet", "packet too large"));
}
write!(out, "{:04x}", data.len() + 4)
.and_then(|_| out.write_all(data))
.map_err(|e| error("push packet", e))
}
fn packet(input: &mut impl Read) -> Result<Option<Vec<u8>>> {
let mut header = [0; 4];
input
.read_exact(&mut header)
.map_err(|e| error("push packet header", e))?;
let len = std::str::from_utf8(&header)
.ok()
.and_then(|s| u16::from_str_radix(s, 16).ok())
.ok_or_else(|| error("push packet", "invalid length"))? as usize;
if len == 0 {
return Ok(None);
}
if !(4..=65520).contains(&len) {
return Err(error("push packet", "invalid packet length"));
}
let mut bytes = vec![0; len - 4];
input
.read_exact(&mut bytes)
.map_err(|e| error("push packet body", e))?;
Ok(Some(bytes))
}
pub(crate) struct Advertisement {
pub refs: BTreeMap<String, gix::ObjectId>,
pub caps: BTreeSet<String>,
}
fn advertise(input: &mut impl Read) -> Result<Advertisement> {
if packet(input)?.as_deref() != Some(b"# service=git-receive-pack\n")
|| packet(input)?.is_some()
{
return Err(error(
"push advertisement",
"expected smart HTTP git-receive-pack service",
));
}
advertise_refs(input)
}
/// SSH hook: git-receive-pack exec channels begin directly with these ref packets,
/// without the smart-HTTP service prelude. commands(), pack(), and report() are also transport-neutral.
pub(crate) fn advertise_refs(input: &mut impl Read) -> Result<Advertisement> {
let mut refs = BTreeMap::new();
let mut caps = BTreeSet::new();
let mut first = true;
let mut count = 0;
while let Some(line) = packet(input)? {
count += 1;
if count > 100_000 {
return Err(error("push advertisement", "too many refs"));
}
let text = std::str::from_utf8(&line)
.map_err(|e| error("push advertisement", e))?
.trim_end_matches('\n');
if let Some(message) = text.strip_prefix("ERR ") {
return Err(error("push remote", message));
}
if text.starts_with("shallow ") {
return Err(error(
"push advertisement",
"shallow remotes are not supported",
));
}
let (reference, capabilities) = text
.split_once('\0')
.map(|(a, b)| (a, Some(b)))
.unwrap_or((text, None));
if let Some(capabilities) = capabilities {
if !first {
return Err(error("push advertisement", "capabilities after first ref"));
}
caps.extend(capabilities.split_ascii_whitespace().map(str::to_owned));
}
first = false;
let (oid, name) = reference
.split_once(' ')
.ok_or_else(|| error("push advertisement", "invalid ref line"))?;
let id =
gix::ObjectId::from_hex(oid.as_bytes()).map_err(|e| error("push advertisement", e))?;
if id.kind() != gix::hash::Kind::Sha1 {
return Err(error("push", "only SHA-1 repositories supported"));
}
if !id.is_null() {
refs.insert(name.to_owned(), id);
}
}
if !caps.contains("report-status") {
return Err(error("push", "remote does not support report-status"));
}
if caps.contains("object-format=sha256") {
return Err(error("push", "SHA-256 remote is unsupported"));
}
Ok(Advertisement { refs, caps })
}
pub(crate) fn commands(
old: Option<gix::ObjectId>,
plan: &Plan,
caps: &[&str],
out: &mut impl Write,
) -> Result<()> {
let zero = gix::hash::Kind::Sha1.null();
let command = format!(
"{} {} {}\0{}\n",
old.unwrap_or(zero),
plan.new.unwrap_or(zero),
plan.destination,
caps.join(" ")
);
pkt(out, command.as_bytes())?;
out.write_all(b"0000")
.map_err(|e| error("push commands", e))
}
/// Unknown remote tips are not assumed to be ancestors. Force is required until fetched.
pub(crate) fn is_ancestor(
repo: &gix::Repository,
old: gix::ObjectId,
new: gix::ObjectId,
) -> Result<bool> {
if old == new {
return Ok(true);
}
if repo
.try_find_object(old)
.map_err(|e| error("push ancestry", e))?
.is_none()
{
return Ok(false);
}
let walk = repo
.find_commit(new)
.map_err(|e| error("push ancestry", e))?
.ancestors()
.use_commit_graph(false)
.all()
.map_err(|e| error("push ancestry", e))?;
for item in walk {
if item.map_err(|e| error("push ancestry", e))?.id == old {
return Ok(true);
}
}
Ok(false)
}
/// Walk the object graph once, stopping BEFORE decoding seen objects or remote boundaries.
/// Returns the number of objects decoded, also used by the traversal-cost regression tests.
/// The boundary must be transitively closed (the reachable remote object set).
/// Gitlink commits belong to another repository and are never followed.
pub(crate) fn reachable(
repo: &gix::Repository,
tip: gix::ObjectId,
seen: &mut BTreeSet<gix::ObjectId>,
boundary: &BTreeSet<gix::ObjectId>,
) -> Result<usize> {
let mut pending = vec![tip];
let mut decoded = 0;
while let Some(id) = pending.pop() {
if boundary.contains(&id) || !seen.insert(id) {
continue;
}
let obj = repo
.find_object(id)
.map_err(|e| error("push reachable object", e))?;
decoded += 1;
match obj.kind {
gix::objs::Kind::Commit => {
let commit = obj.try_into_commit().map_err(|e| error("push commit", e))?;
pending.push(
commit
.tree_id()
.map_err(|e| error("push tree", e))?
.detach(),
);
pending.extend(commit.parent_ids().map(|id| id.detach()));
}
// Follow one tag at a time, including intermediate annotated tags in seen.
gix::objs::Kind::Tag => {
let tag = obj
.try_into_tag()
.map_err(|e| error("push remote tag", e))?;
pending.push(
tag.target_id()
.map_err(|e| error("push remote tag target", e))?
.detach(),
);
}
gix::objs::Kind::Tree => {
let tree = obj.try_into_tree().map_err(|e| error("push tree", e))?;
for entry in tree.iter() {
let entry = entry.map_err(|e| error("push tree entry", e))?;
if entry.mode().is_tree() {
pending.push(entry.object_id());
} else if !entry.mode().is_commit() && !boundary.contains(&entry.object_id()) {
seen.insert(entry.object_id());
}
}
}
gix::objs::Kind::Blob => {}
}
}
Ok(decoded)
}
pub(crate) fn objects_to_send(
repo: &gix::Repository,
new: gix::ObjectId,
remote: impl IntoIterator<Item = gix::ObjectId>,
) -> Result<BTreeSet<gix::ObjectId>> {
let mut theirs = BTreeSet::new();
let empty = BTreeSet::new();
for id in remote {
if !theirs.contains(&id)
&& repo
.try_find_object(id)
.map_err(|e| error("push remote object", e))?
.is_some()
{
reachable(repo, id, &mut theirs, &empty)?;
}
}
let mut ours = BTreeSet::new();
reachable(repo, new, &mut ours, &theirs)?;
Ok(ours)
}
/// Compress one object at a time and stream pack bytes to the HTTP request spool.
pub(crate) fn pack(
repo: &gix::Repository,
ids: &BTreeSet<gix::ObjectId>,
out: &mut impl Write,
) -> Result<()> {
use gix_pack::data::output::{Count, Entry, bytes::FromEntriesIter};
let count = u32::try_from(ids.len()).map_err(|e| error("push pack count", e))?;
let entries = ids.iter().map(|id| {
let object = repo.find_object(*id)?;
Entry::from_data(
&Count::from_data(*id, None),
&gix::objs::Data {
kind: object.kind,
data: &object.data,
object_hash: gix::hash::Kind::Sha1,
},
gix_zlib::Compression::default(),
)
.map(|entry| vec![entry])
});
for written in FromEntriesIter::new(
entries,
out,
count,
gix_pack::data::Version::V2,
gix::hash::Kind::Sha1,
) {
written.map_err(|e| error("push pack", format!("{e:#}")))?;
}
Ok(())
}
fn parse_status(input: &mut impl Read, reference: &str) -> Result<()> {
let unpack = packet(input)?.ok_or_else(|| error("push status", "missing unpack status"))?;
let unpack = String::from_utf8_lossy(&unpack);
if unpack.trim_end() != "unpack ok" {
return Err(error("push remote", unpack.trim_end()));
}
let mut confirmed = false;
while let Some(line) = packet(input)? {
let line = String::from_utf8_lossy(&line);
let line = line.trim_end();
if line == format!("ok {reference}") && !confirmed {
confirmed = true;
} else if let Some(message) = line.strip_prefix("ng ") {
return Err(error("push rejected", message));
} else {
return Err(error("push status", format!("unexpected status: {line}")));
}
}
if !confirmed {
return Err(error(
"push status",
"remote did not confirm the destination ref",
));
}
Ok(())
}
// Hook explanations often arrive on band 2, with only a generic reason in ng.
// Keep a bounded byte tail (lossy UTF-8 conversion happens only when rendering it).
const REMOTE_DETAIL_LIMIT: usize = 8192;
fn with_remote_detail(result: Result<()>, tail: &[u8]) -> Result<()> {
result.map_err(|mut e| {
let detail = String::from_utf8_lossy(tail);
if !detail.trim().is_empty() {
e.message.push_str(&format!("\nremote: {}", detail.trim()));
}
e
})
}
pub(crate) fn report(input: &mut impl Read, reference: &str, sideband: bool) -> Result<()> {
if !sideband {
return parse_status(input, reference);
}
let mut status = Vec::new();
let mut remote_detail = Vec::new();
while let Some(line) = packet(input)? {
match line.split_first() {
Some((1, bytes)) => {
if status.len() + bytes.len() > 1024 * 1024 {
return Err(error("push status", "report-status too large"));
}
status.extend_from_slice(bytes);
}
Some((2, bytes)) => {
remote_detail.extend_from_slice(bytes);
if remote_detail.len() > REMOTE_DETAIL_LIMIT {
remote_detail.drain(..remote_detail.len() - REMOTE_DETAIL_LIMIT);
}
}
Some((3, bytes)) => {
return with_remote_detail(
Err(error(
"push remote",
String::from_utf8_lossy(bytes).trim_end(),
)),
&remote_detail,
);
}
_ => return Err(error("push sideband", "invalid channel")),
}
}
with_remote_detail(
parse_status(&mut status.as_slice(), reference),
&remote_detail,
)
}
fn update_local(repo: &gix::Repository, remote: &str, plan: &Plan, upstream: bool) -> Result<()> {
let short = plan
.destination
.strip_prefix("refs/heads/")
.ok_or_else(|| error("push tracking", "not a branch"))?;
let tracking = format!("refs/remotes/{remote}/{short}");
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
if let Some(new) = plan.new {
repo.edit_references_as(
[RefEdit {
name: tracking
.as_str()
.try_into()
.map_err(|e| error("push tracking", e))?,
deref: false,
change: Change::Update {
log: Default::default(),
expected: PreviousValue::Any,
new: Target::Object(new),
},
}],
None,
)
.map_err(|e| error("push tracking", e))?;
} else if let Some(reference) = repo
.try_find_reference(&tracking)
.map_err(|e| error("push tracking", e))?
{
reference.delete().map_err(|e| error("push tracking", e))?;
}
if upstream {
let branch = plan
.source
.as_deref()
.and_then(|s| s.strip_prefix("refs/heads/"))
.ok_or_else(|| error("push upstream", "missing source branch"))?;
let mut config = remotes::load(repo)?;
let ids: Vec<_> = config
.sections_and_ids_by_name("branch")
.into_iter()
.flatten()
.filter(|(s, _)| {
s.header()
.subsection_name()
.is_some_and(|s| s == branch.as_bytes())
})
.map(|(_, id)| id)
.collect();
for id in ids {
if let Some(mut section) = config.section_mut_by_id(id) {
while section.remove("remote").is_some() {}
while section.remove("merge").is_some() {}
}
}
let mut section = config
.section_mut_or_create_new("branch", branch)
.map_err(|e| error("push upstream", e))?;
section
.push("remote", Some(remote.into()))
.map_err(|e| error("push upstream", e))?;
section
.push("merge", Some(plan.destination.as_str().into()))
.map_err(|e| error("push upstream", e))?;
remotes::save(repo, &config)?;
}
Ok(())
}
/// Send one reference update through the smart-HTTP receive-pack service.
pub fn push(p: PushParams) -> Result<PushResult> {
let mut repo = repo::open(&p.repo)?;
repo.refs.write_reflog = gix::refs::store::WriteReflog::Disable;
let remote = p.remote.as_deref().unwrap_or("origin");
remotes::validate_name(remote)?;
let plan = plan(&repo, &p)?;
if plan.new.is_some()
&& repo
.path()
.join("shallow")
.try_exists()
.map_err(|e| error("push shallow", e))?
{
return Err(error(
"push",
"shallow pushes are not supported; fetch full history first",
));
}
let configured = repo
.find_remote(remote)
.map_err(|_| error("push", "configured remote not found"))?;
let url = configured
.url(gix::remote::Direction::Push)
.ok_or_else(|| error("push", "remote has no push URL"))?
.to_bstring()
.to_str_lossy()
.into_owned();
let base = clean_url(&url)
.map_err(|e| error("push URL", e))?
.to_string()
.trim_end_matches('/')
.to_owned();
let mut http = WasiHttp::default();
let result = (|| {
let mut discovery = http
.get(
&format!("{base}/info/refs?service=git-receive-pack"),
&base,
["Accept: application/x-git-receive-pack-advertisement"],
)
.map_err(|e| error("push discovery", format!("{e:#}")))?;
let advertised = advertise(&mut discovery.body)?;
drop(discovery);
let old = advertised.refs.get(&plan.destination).copied();
if let (Some(old), Some(new)) = (old, plan.new) {
if old != new && !plan.force && !is_ancestor(&repo, old, new)? {
return Err(error(
"push",
"non-fast-forward (or remote tip unknown locally); fetch first or use force",
));
}
}
let up_to_date = old == plan.new;
let mut objects = 0;
if !up_to_date {
if plan.new.is_none() && !advertised.caps.contains("delete-refs") {
return Err(error("push", "remote does not allow deleting refs"));
}
let ids = match plan.new {
Some(new) => objects_to_send(&repo, new, advertised.refs.values().copied())?,
None => BTreeSet::new(),
};
objects = ids.len();
let sideband = advertised.caps.contains("side-band-64k");
let mut caps = vec!["report-status"];
if sideband {
caps.push("side-band-64k");
}
if advertised.caps.iter().any(|c| c.starts_with("agent=")) {
caps.push("agent=sausage-git/0.1");
}
let base = http.redirected_base_url().unwrap_or(base);
let mut post = http
.post(
&format!("{base}/git-receive-pack"),
&base,
[
"Content-Type: application/x-git-receive-pack-request",
"Accept: application/x-git-receive-pack-result",
],
PostBodyDataKind::Unbounded,
)
.map_err(|e| error("push request", format!("{e:#}")))?;
commands(old, &plan, &caps, &mut post.post_body)?;
if plan.new.is_some() {
pack(&repo, &ids, &mut post.post_body)?;
}
drop(post.post_body);
report(&mut post.body, &plan.destination, sideband)?;
}
update_local(&repo, remote, &plan, p.set_upstream.unwrap_or(false)).map_err(|e| {
error(
"push succeeded remotely, but local state update failed",
e.message,
)
})?;
Ok(PushResult {
remote: remote.into(),
reference: plan.destination,
old: old.map(|id| id.to_string()),
new: plan.new.map(|id| id.to_string()),
up_to_date,
objects,
})
})();
result
}
+581
View File
@@ -0,0 +1,581 @@
//! Linear rebase sequencer. Every entry point is called under the common .git lock.
//!
//! Own persistent format (NOT Git's sequencer format): rebase-merge/sausage.json,
//! version 1. Full hex OIDs; onto/orig_head/orig_tip/branch are immutable; todo is
//! oldest-first, done lists consumed original commits (including skipped/empty),
//! current is the conflicted original commit. tip is the unpublished replay tip.
//! work_tree records the last materialized tree; orig_index is base64 of the exact
//! original index. phase checkpoints precede worktree/ref writes. Atomic JSON rename
//! makes each pick restartable; refs stay original until Finish. New fields must
//! default; unknown versions refuse. Real git is neither used nor expected to read it.
use crate::Result;
use crate::{branches, checkout, merge, repo::error, staging};
use base64::{Engine, engine::general_purpose::STANDARD};
use gix::bstr::ByteSlice;
use std::{collections::BTreeSet, fs};
/// Replay a local branch's linear commits onto another revision.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RebaseParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// New base; defaults to the selected branch's configured upstream.
pub onto: Option<String>,
/// Short or full local branch name; defaults to current branch (not detached HEAD).
pub branch: Option<String>,
}
/// Rebase progress. HEAD and the branch ref remain original until completion.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RebaseResult {
/// HEAD after this call (original while pending).
pub head: crate::HeadInfo,
/// Unpublished replay tip while pending; final branch tip otherwise.
pub tip: String,
/// True when continue, skip or abort is needed.
pub pending: bool,
/// True when the selected branch already contains the requested base.
pub already_up_to_date: bool,
/// True when no replay was required and the branch advanced to onto.
pub fast_forward: bool,
/// Sorted unresolved paths in stages 1/2/3.
pub conflicts: Vec<String>,
/// Checkout limitations, including preserved abort obstructions.
pub warnings: Vec<String>,
/// Commit created from a staged conflict resolution during this call.
pub resolved: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Eq)]
enum Phase {
Replay,
CheckoutConflict,
Conflict,
Reset,
Finish,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct State {
version: u32,
onto: String,
orig_head: String,
orig_branch: Option<String>,
branch: String,
orig_tip: String,
tip: String,
todo: Vec<String>,
done: Vec<String>,
current: Option<String>,
work_tree: String,
orig_index: Option<String>,
phase: Phase,
already: bool,
fast_forward: bool,
}
fn oid(s: &str) -> Result<gix::ObjectId> {
gix::ObjectId::from_hex(s.as_bytes()).map_err(|e| error("rebase state OID", e))
}
fn tree(repo: &gix::Repository, id: gix::ObjectId) -> Result<gix::ObjectId> {
Ok(repo
.find_commit(id)
.map_err(|e| error("rebase commit", e))?
.tree_id()
.map_err(|e| error("rebase tree", e))?
.detach())
}
fn state_path(repo: &gix::Repository) -> std::path::PathBuf {
repo.git_dir().join("rebase-merge/sausage.json")
}
fn save(repo: &gix::Repository, s: &State) -> Result<()> {
let bytes = serde_json::to_vec(s).map_err(|e| error("rebase state", e))?;
let tmp = repo.git_dir().join("rebase-merge/sausage.json.tmp");
fs::write(&tmp, bytes).map_err(|e| error("rebase state write", e))?;
fs::rename(tmp, state_path(repo)).map_err(|e| error("rebase state rename", e))
}
fn load(repo: &gix::Repository) -> Result<State> {
let bytes = fs::read(state_path(repo))
.map_err(|e| error("rebase: no readable Sausage rebase state", e))?;
let s: State = serde_json::from_slice(&bytes).map_err(|e| error("rebase state", e))?;
if s.version != 1 {
return Err(error("rebase state", "unsupported version"));
}
Ok(s)
}
fn clear(repo: &gix::Repository) -> Result<()> {
fs::remove_file(state_path(repo)).map_err(|e| error("rebase cleanup", e))?;
let tmp = repo.git_dir().join("rebase-merge/sausage.json.tmp");
if tmp.exists() {
fs::remove_file(tmp).map_err(|e| error("rebase cleanup", e))?;
}
// Never recursively remove someone else's sequencer files.
fs::remove_dir(repo.git_dir().join("rebase-merge")).map_err(|e| error("rebase cleanup", e))
}
fn conflicts(repo: &gix::Repository) -> Result<Vec<String>> {
let index = repo
.index_or_empty()
.map_err(|e| error("rebase index", e))?;
Ok(index
.entries()
.iter()
.filter(|e| e.stage_raw() != 0)
.map(|e| e.path(&index).to_str_lossy().into_owned())
.collect::<BTreeSet<_>>()
.into_iter()
.collect())
}
fn result(
repo: &gix::Repository,
s: &State,
pending: bool,
warnings: Vec<String>,
) -> Result<RebaseResult> {
Ok(RebaseResult {
head: crate::head_info(repo)?,
tip: s.tip.clone(),
pending,
already_up_to_date: s.already,
fast_forward: s.fast_forward,
conflicts: if pending { conflicts(repo)? } else { vec![] },
warnings,
resolved: None,
})
}
fn index_lock(repo: &gix::Repository) -> Result<()> {
if repo.git_dir().join("index.lock").exists() {
return Err(error("rebase", "index.lock already exists"));
}
Ok(())
}
fn verify_refs(repo: &gix::Repository, s: &State) -> Result<()> {
let head = crate::head_info(repo)?;
if head.id.as_deref() != Some(&s.orig_head)
|| head.branch != s.orig_branch
|| branches::resolve(repo, &s.branch)? != oid(&s.orig_tip)?
{
return Err(error(
"rebase",
"HEAD or branch changed since rebase began; refusing to overwrite refs",
));
}
Ok(())
}
fn upstream(repo: &gix::Repository, branch: &str) -> Result<String> {
// gix's remote-tracking lookup doesn't map local (remote=".") upstreams.
let short = branch
.strip_prefix("refs/heads/")
.ok_or_else(|| error("rebase upstream", "expected local branch"))?;
let config = repo.config_snapshot();
if config
.string(format!("branch.{short}.remote").as_str())
.is_some_and(|s| s.as_bstr() == b".".as_bstr())
{
return config
.string(format!("branch.{short}.merge").as_str())
.map(|s| s.to_str_lossy().into_owned())
.ok_or_else(|| error("rebase", "no upstream merge branch configured; supply onto"));
}
repo.find_reference(branch)
.map_err(|e| error("rebase branch", e))?
.remote_tracking_ref_name(gix::remote::Direction::Fetch)
.transpose()
.map_err(|e| error("rebase upstream", e))?
.map(|n| n.as_bstr().to_str_lossy().into_owned())
.ok_or_else(|| {
error(
"rebase",
"no upstream configured; supply onto or configure branch upstream",
)
})
}
pub fn start(repo: &gix::Repository, p: RebaseParams) -> Result<RebaseResult> {
let local = merge::merge_repository(repo)?;
let repo = &local;
merge::ensure_idle(repo)?;
merge::clean(repo)?;
let head = crate::head_info(repo)?;
let orig_head = head
.id
.ok_or_else(|| error("rebase", "unborn HEAD is unsupported"))?;
let branch = p
.branch
.map(|b| {
if b.starts_with("refs/heads/") {
b
} else {
format!("refs/heads/{b}")
}
})
.or_else(|| head.branch.clone())
.ok_or_else(|| error("rebase", "detached HEAD; supply a local branch"))?;
let _: gix::refs::FullName = branch
.as_str()
.try_into()
.map_err(|e| error("rebase branch", e))?;
if !branch.starts_with("refs/heads/") {
return Err(error("rebase", "expected local branch"));
}
let orig = branches::resolve(repo, &branch)?;
let onto = branches::resolve(
repo,
&match p.onto {
Some(r) => r,
None => upstream(repo, &branch)?,
},
)?;
let base = repo
.merge_base(orig, onto)
.map_err(|e| error("rebase merge-base", e))?
.ok_or_else(|| error("rebase", "unrelated histories are unsupported"))?
.detach();
let mut cursor = orig;
let mut todo = Vec::new();
while cursor != base {
let c = repo
.find_commit(cursor)
.map_err(|e| error("rebase range", e))?;
let parents: Vec<_> = c.parent_ids().map(|id| id.detach()).collect();
if parents.len() != 1 {
return Err(error(
"rebase",
"linear history only: merge commit in replay range (no rebase-merges)",
));
}
todo.push(cursor.to_string());
cursor = parents[0];
}
todo.reverse();
let already = base == onto;
if already && head.branch.as_deref() == Some(&branch) {
return Ok(RebaseResult {
head: crate::head_info(repo)?,
tip: orig.to_string(),
pending: false,
already_up_to_date: true,
fast_forward: false,
conflicts: vec![],
warnings: vec![],
resolved: None,
});
}
if already {
todo.clear();
}
if !todo.is_empty() {
staging::signature(None, None)?;
}
// Preflight reflog configuration before materializing anything.
repo.committer()
.transpose()
.map_err(|e| error("rebase identity", e))?;
let orig_index = match fs::read(repo.git_dir().join("index")) {
Ok(b) => Some(STANDARD.encode(b)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(error("rebase snapshot index", e)),
};
let s = State {
version: 1,
onto: onto.to_string(),
orig_head: orig_head.clone(),
orig_branch: head.branch,
branch,
orig_tip: orig.to_string(),
tip: if already { orig } else { onto }.to_string(),
todo,
done: vec![],
current: None,
work_tree: tree(repo, oid(&orig_head)?)?.to_string(),
orig_index,
phase: Phase::Replay,
already,
fast_forward: !already && base == orig,
};
fs::create_dir(repo.git_dir().join("rebase-merge"))
.map_err(|e| error("rebase state directory", e))?;
save(repo, &s)?;
run(repo, s, vec![])
}
fn pick(
repo: &gix::Repository,
original: &str,
tip: &str,
new_tree: gix::ObjectId,
) -> Result<String> {
if new_tree == tree(repo, oid(tip)?)? {
return Ok(tip.to_owned());
}
let original = repo
.find_commit(oid(original)?)
.map_err(|e| error("rebase original", e))?;
let decoded = original.decode().map_err(|e| error("rebase original", e))?;
let author = decoded
.author()
.map_err(|e| error("rebase author", e))?
.to_owned()
.map_err(|e| error("rebase author", e))?;
let object = gix::objs::Commit {
tree: new_tree,
parents: vec![oid(tip)?].into_iter().collect(),
author,
committer: staging::signature(None, None)?,
encoding: decoded.encoding.map(ToOwned::to_owned),
message: decoded.message.to_owned(),
extra_headers: vec![],
};
Ok(repo
.write_object(&object)
.map_err(|e| error("rebase commit write", e))?
.to_string())
}
fn consume(s: &mut State) {
if !s.todo.is_empty() {
s.done.push(s.todo.remove(0));
}
s.current = None;
}
fn run(repo: &gix::Repository, mut s: State, mut warnings: Vec<String>) -> Result<RebaseResult> {
verify_refs(repo, &s)?;
while let Some(current) = s.todo.first().cloned() {
let commit = repo
.find_commit(oid(&current)?)
.map_err(|e| error("rebase pick", e))?;
let parent = commit
.parent_ids()
.next()
.ok_or_else(|| error("rebase pick", "root commit in range"))?
.detach();
let labels = gix::merge::blob::builtin_driver::text::Labels {
ancestor: None,
current: Some("ours".into()),
other: Some("theirs".into()),
};
let mut outcome = repo
.merge_trees(
tree(repo, parent)?,
tree(repo, oid(&s.tip)?)?,
tree(repo, oid(&current)?)?,
labels,
repo.tree_merge_options()
.map_err(|e| error("rebase options", e))?,
)
.map_err(|e| error("rebase merge trees", e))?;
let unresolved = outcome.has_unresolved_conflicts(Default::default());
let merged = outcome
.tree
.write()
.map_err(|e| error("rebase tree write", e))?
.detach();
let mut index = gix::index::State::from_tree(&merged, &repo.objects, Default::default())
.map_err(|e| error("rebase index", e))?;
outcome.index_changed_after_applying_conflicts(
&mut index,
Default::default(),
gix::merge::tree::apply_index_entries::RemovalMode::Prune,
);
index.sort_entries();
let has_conflicts = index.entries().iter().any(|e| e.stage_raw() != 0);
if unresolved && !has_conflicts {
return Err(error(
"rebase",
"gix conflict without index stages; refusing unsafe replay",
));
}
if has_conflicts {
s.current = Some(current);
s.phase = Phase::CheckoutConflict;
save(repo, &s)?;
warnings.extend(checkout::write_tree_to_worktree(
repo,
Some(oid(&s.work_tree)?),
merged,
false,
)?);
staging::publish(repo, index)?;
s.work_tree = merged.to_string();
s.phase = Phase::Conflict;
save(repo, &s)?;
return result(repo, &s, true, warnings);
}
s.tip = pick(repo, &current, &s.tip, merged)?;
consume(&mut s);
save(repo, &s)?;
}
s.phase = Phase::Finish;
save(repo, &s)?;
finish(repo, s, warnings)
}
fn finish(repo: &gix::Repository, s: State, mut warnings: Vec<String>) -> Result<RebaseResult> {
verify_refs(repo, &s)?;
let target = tree(repo, oid(&s.tip)?)?;
// A process may exit after checkout publishes its index but before the ref CAS.
// In that case the worktree is already final; do not compare it to the old tree.
if resolved_tree(repo)? != target {
warnings.extend(checkout::write_tree_to_worktree(
repo,
Some(oid(&s.work_tree)?),
target,
false,
)?);
}
use gix::refs::{
Target,
transaction::{Change, PreviousValue, RefEdit},
};
let old_head = match &s.orig_branch {
Some(b) => Target::Symbolic(b.as_str().try_into().map_err(|e| error("rebase HEAD", e))?),
None => Target::Object(oid(&s.orig_head)?),
};
warnings.extend(branches::edit_refs(
repo,
vec![
RefEdit {
name: s
.branch
.as_str()
.try_into()
.map_err(|e| error("rebase branch", e))?,
deref: false,
change: Change::Update {
expected: PreviousValue::MustExistAndMatch(Target::Object(oid(&s.orig_tip)?)),
new: Target::Object(oid(&s.tip)?),
log: Default::default(),
},
},
RefEdit {
name: "HEAD".try_into().map_err(|e| error("rebase HEAD", e))?,
deref: false,
change: Change::Update {
expected: PreviousValue::MustExistAndMatch(old_head),
new: Target::Symbolic(
s.branch
.as_str()
.try_into()
.map_err(|e| error("rebase branch", e))?,
),
log: Default::default(),
},
},
],
)?);
clear(repo)?;
result(repo, &s, false, warnings)
}
/// Check only unstaged changes: HEAD intentionally still names the original branch tip.
fn resolved_tree(repo: &gix::Repository) -> Result<gix::ObjectId> {
let index = repo
.index_or_empty()
.map_err(|e| error("rebase index", e))?;
let tree = staging::tree_from_index(repo, &index)?;
let status = crate::changes::status(repo)?;
if !status.unstaged.is_empty() {
return Err(error(
"rebase continue",
"unstaged changes; stage the resolved paths first",
));
}
Ok(tree)
}
pub fn resume(repo: &gix::Repository, skip: bool) -> Result<RebaseResult> {
index_lock(repo)?;
let local = merge::merge_repository(repo)?;
let repo = &local;
let mut s = load(repo)?;
// Ref transaction completed but cleanup was interrupted: do not replay twice.
let head = crate::head_info(repo)?;
if s.phase == Phase::Finish
&& head.branch.as_deref() == Some(&s.branch)
&& head.id.as_deref() == Some(&s.tip)
&& branches::resolve(repo, &s.branch)? == oid(&s.tip)?
{
clear(repo)?;
return result(repo, &s, false, vec![]);
}
verify_refs(repo, &s)?;
if s.phase == Phase::CheckoutConflict {
return Err(error(
"rebase",
"interrupted conflict publication; use rebase_abort (resolution index may be incomplete)",
));
}
if skip && s.phase != Phase::Conflict {
return Err(error("rebase skip", "no conflicted pick to skip"));
}
let mut warnings = vec![];
let mut resolved = None;
if s.phase == Phase::Conflict {
if skip {
consume(&mut s);
s.phase = Phase::Reset;
save(repo, &s)?;
} else {
let merged = resolved_tree(repo)?;
let current = s
.current
.as_deref()
.ok_or_else(|| error("rebase state", "missing current pick"))?;
s.tip = pick(repo, current, &s.tip, merged)?;
resolved = Some(s.tip.clone());
s.work_tree = merged.to_string();
consume(&mut s);
s.phase = Phase::Replay;
save(repo, &s)?;
}
}
if s.phase == Phase::Reset {
let target = tree(repo, oid(&s.tip)?)?;
warnings.extend(checkout::write_tree_to_worktree(
repo,
Some(oid(&s.work_tree)?),
target,
true,
)?);
s.work_tree = target.to_string();
s.phase = Phase::Replay;
save(repo, &s)?;
}
if s.phase == Phase::Finish {
return finish(repo, s, warnings);
}
if resolved_tree(repo)? != oid(&s.work_tree)? {
return Err(error(
"rebase",
"index changed during interrupted replay; abort or restore the index before continuing",
));
}
let mut result = run(repo, s, warnings)?;
result.resolved = resolved;
Ok(result)
}
pub fn abort(repo: &gix::Repository) -> Result<RebaseResult> {
index_lock(repo)?;
let s = load(repo)?;
verify_refs(repo, &s)?;
let warnings = checkout::abort_to_tree(repo, tree(repo, oid(&s.orig_head)?)?)?;
if let Some(bytes) = &s.orig_index {
use std::io::Write;
let bytes = STANDARD
.decode(bytes)
.map_err(|e| error("rebase original index", e))?;
let lock = repo.git_dir().join("index.lock");
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock)
.map_err(|e| error("rebase index lock", e))?;
let write = file.write_all(&bytes).and_then(|_| file.flush());
drop(file);
if let Err(e) = write {
let _ = fs::remove_file(&lock);
return Err(error("rebase index restore", e));
}
fs::rename(&lock, repo.git_dir().join("index"))
.map_err(|e| error("rebase index restore", e))?;
} else {
match fs::remove_file(repo.git_dir().join("index")) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(error("rebase index restore", e)),
}
}
clear(repo)?;
let mut r = result(repo, &s, false, warnings)?;
r.tip = s.orig_tip;
Ok(r)
}
+187
View File
@@ -0,0 +1,187 @@
//! Non-destructive local Git config edits. No plugin-owned persistent format.
use crate::Result;
use crate::{http::clean_url, repo::error};
use gix::bstr::ByteSlice;
/// Remote identity.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RemoteParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Remote name.
pub name: String,
}
/// Add or change a remote URL.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RemoteUrlParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Remote name.
pub name: String,
/// HTTPS without credentials, ssh:// URL, or [user@]host:path.
pub url: String,
}
/// Remote settings stored in local .git/config.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct RemoteInfo {
/// Remote name.
pub name: String,
/// Credential-free URL, if configured.
pub url: Option<String>,
/// Fetch refspecs in config order.
pub fetch: Vec<String>,
}
pub fn validate_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.starts_with('-')
|| name.contains('/')
|| name.contains("..")
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
{
return Err(error(
"remote",
"expected a remote name using letters, digits, ._-",
));
}
let _: gix::refs::FullName = format!("refs/remotes/{name}/branch")
.as_str()
.try_into()
.map_err(|e| error("remote name", e))?;
Ok(())
}
pub fn load(repo: &gix::Repository) -> Result<gix::config::File> {
let text = std::fs::read_to_string(repo.common_dir().join("config"))
.map_err(|e| error("remote config", e))?;
gix::config::File::try_from(text.as_str())
.map_err(|_| error("remote config", "could not parse .git/config"))
}
pub fn save(repo: &gix::Repository, config: &gix::config::File) -> Result<()> {
let mut bytes = Vec::new();
config
.write_to(&mut bytes)
.map_err(|e| error("remote config", e))?;
std::fs::write(repo.common_dir().join("config"), bytes).map_err(|e| error("config write", e))
}
pub fn list(repo: &gix::Repository) -> Result<Vec<RemoteInfo>> {
let config = load(repo)?;
let mut names = std::collections::BTreeSet::new();
for section in config.sections_by_name("remote").into_iter().flatten() {
if let Some(name) = section.header().subsection_name() {
names.insert(name.to_str_lossy().into_owned());
}
}
names
.into_iter()
.map(|name| {
let url = config
.string_by("remote", name.as_str(), "url")
.map(|s| s.to_str_lossy().into_owned());
// Do not return legacy embedded credentials from repositories imported outside this plugin.
let url = url
.map(|s| {
clean_url(&s).map(|u| u.to_string()).map_err(|_| {
error(
"remote list",
"remote URL must be HTTPS without credentials or SSH without password",
)
})
})
.transpose()?;
let fetch = config
.strings_by("remote", name.as_str(), "fetch")
.unwrap_or_default()
.into_iter()
.map(|s| s.to_str_lossy().into_owned())
.collect();
Ok(RemoteInfo { name, url, fetch })
})
.collect()
}
pub fn set(repo: &gix::Repository, name: &str, url: &str, add: bool) -> Result<RemoteInfo> {
validate_name(name)?;
let url = clean_url(url)
.map_err(|e| error("remote URL", e))?
.to_string();
let mut config = load(repo)?;
let exists = config.section("remote", name).is_ok();
if add == exists {
return Err(error(
"remote",
if add {
"remote already exists"
} else {
"remote does not exist"
},
));
}
// Replace all URL occurrences (including duplicate sections), preserving other keys/comments.
if !add {
let ids: Vec<_> = config
.sections_and_ids_by_name("remote")
.into_iter()
.flatten()
.filter(|(s, _)| {
s.header()
.subsection_name()
.is_some_and(|s| s == name.as_bytes())
})
.map(|(_, id)| id)
.collect();
for id in ids {
if let Some(mut section) = config.section_mut_by_id(id) {
while section.remove("url").is_some() {}
}
}
}
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(
"fetch",
Some(
format!("+refs/heads/*:refs/remotes/{name}/*")
.as_str()
.into(),
),
)
.map_err(|e| error("remote config", e))?;
}
save(repo, &config)?;
list(repo)?
.into_iter()
.find(|r| r.name == name)
.ok_or_else(|| error("remote", "remote missing after config edit"))
}
pub fn remove(repo: &gix::Repository, name: &str) -> Result<()> {
validate_name(name)?;
let mut config = load(repo)?;
if config.remove_section("remote", name).is_none() {
return Err(error("remote", "remote does not exist"));
}
while config.remove_section("remote", name).is_some() {}
save(repo, &config)
}
/// Configure the newly cloned branch's upstream with the same non-destructive editor.
pub fn upstream(repo: &gix::Repository, branch: &str) -> Result<()> {
let mut config = load(repo)?;
let mut section = config
.section_mut_or_create_new("branch", branch)
.map_err(|e| error("branch config", e))?;
section
.push("remote", Some("origin".into()))
.map_err(|e| error("branch config", e))?;
section
.push(
"merge",
Some(format!("refs/heads/{branch}").as_str().into()),
)
.map_err(|e| error("branch config", e))?;
save(repo, &config)
}
+112
View File
@@ -0,0 +1,112 @@
//! Repository layout and shared operation helpers. Persistent state is ordinary Git files only.
use crate::{Error, Result};
use std::path::PathBuf;
/// Add operation context to Git and filesystem errors.
pub fn error(operation: &str, error: impl std::fmt::Display) -> Error {
Error::new(-32000, format!("{operation}: {error}"))
}
/// Resolve a legacy name or a literal absolute path under /plugins/ or /repos/.
pub fn repo_path(name: &str) -> Result<PathBuf> {
Ok(PathBuf::from(name))
}
/// Resolve metadata without opening another repository or acquiring another lock.
/// Missing .git is retained for the existing non-bare init/clone lock key.
fn git_dir(path: &std::path::Path) -> Result<PathBuf> {
let dot_git = path.join(".git");
if dot_git.is_file() {
let text = std::fs::read_to_string(&dot_git).map_err(|e| error("gitfile", e))?;
let target = text
.trim_end_matches(['\r', '\n'])
.strip_prefix("gitdir: ")
.filter(|p| !p.is_empty() && !p.contains(['\r', '\n', '\0']))
.ok_or_else(|| error("gitfile", "expected gitdir: <path>"))?;
return std::fs::canonicalize(path.join(target)).map_err(|e| error("gitfile target", e));
}
if !dot_git.exists() && path.join("HEAD").is_file() && path.join("objects").is_dir() {
return Ok(path.to_owned());
}
Ok(dot_git)
}
/// Common-dir advisory lock key, shared by the main and all linked worktrees.
/// A plain .git directory retains exactly the legacy lock path.
pub fn lock_path(name: &str) -> Result<PathBuf> {
let git_dir = git_dir(&repo_path(name)?)?;
let common = match std::fs::read_to_string(git_dir.join("commondir")) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(git_dir),
Err(e) => return Err(error("commondir", e)),
};
let common = common.trim_end_matches(['\r', '\n']);
if common.is_empty() || common.contains(['\r', '\n', '\0']) {
return Err(error("commondir", "expected one nonempty path"));
}
std::fs::canonicalize(git_dir.join(common)).map_err(|e| error("commondir target", e))
}
/// Open only the addressed worktree or bare directory, never discover an ancestor.
pub fn open(name: &str) -> Result<gix::Repository> {
let path = repo_path(name)?;
let dot_git = path.join(".git");
let mut repo = if dot_git.is_file() {
// gix follows gitfiles and commondir, including the private HEAD/index.
git_dir(&path)?; // Give malformed gitfiles a useful error before opening.
let repo = gix::open(&path).map_err(|e| error("open", e))?;
// gix 0.90 reports is_bare()==true from the common config even though
// it resolves the workdir and private index correctly. Check workdir,
// not that cached flag; status and our tree writer work without config edits.
worktree(&repo)?;
repo
} else if dot_git.is_dir() {
let repo = gix::open(&dot_git).map_err(|e| error("open", e))?;
if repo.workdir().is_none() || repo.is_bare() {
return Err(error(
"open",
"expected worktree metadata in .git; address bare repositories by their bare directory",
));
}
repo
} else {
let repo = gix::open_opts(&path, gix::open::Options::default().open_path_as_is(true))
.map_err(|e| error("open bare", e))?;
if !repo.is_bare() {
return Err(error(
"open",
"expected a worktree root or bare repository directory",
));
}
repo
};
// gix-ref otherwise uses unsupported mmap for packed-refs above 32 KiB.
#[cfg(target_os = "wasi")]
repo.refs.set_packed_buffer_mmap_threshold(u64::MAX);
Ok(repo)
}
/// Require a worktree before any status/index/worktree-style operation.
pub fn worktree(repo: &gix::Repository) -> Result<&std::path::Path> {
repo.workdir().ok_or_else(|| {
error(
"worktree",
"no worktree: operation is not available on a bare repository",
)
})
}
/// Validate a literal repository-relative path (not a pathspec).
pub fn validate_path(path: &str) -> Result<()> {
if path.is_empty()
|| path
.split('/')
.any(|c| c.is_empty() || c == "." || c == "..")
{
return Err(error(
"path",
"expected a nonempty repository-relative literal path",
));
}
Ok(())
}
+771
View File
@@ -0,0 +1,771 @@
//! Staging uses ordinary Git index entries; callers hold the repository .git lock.
use crate::Result;
use crate::repo::{self, error};
use gix::{
bstr::ByteSlice,
index::{
State,
entry::{Flags, Mode, Stat},
},
};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
/// Stage literal paths or the whole worktree.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct AddParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Literal files/directories; no globbing. May be empty with all=true.
pub paths: Vec<String>,
/// Stage tracked changes/deletions and nonignored untracked files throughout the repo.
pub all: Option<bool>,
}
/// Reset selected index paths to HEAD, leaving worktree bytes untouched.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct PathsParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Nonempty list of literal file/directory paths; no globbing.
pub paths: Vec<String>,
}
/// Remove tracked paths from the index and optionally worktree.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct RmParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Nonempty list of literal file/directory paths.
pub paths: Vec<String>,
/// Leave files on disk (default false). Otherwise refuse staged or local modifications.
pub keep_file: Option<bool>,
}
/// Paths whose index entries were selected by an operation.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct StagedPaths {
/// Selected paths, sorted, including staged deletions.
pub paths: Vec<String>,
}
/// Create a commit from the stage-zero index.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct CommitParams {
/// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash).
pub repo: String,
/// Nonblank commit message, stored verbatim.
pub message: String,
/// Author display name, falling back to /config/git.yaml user.name.
pub author_name: Option<String>,
/// Author email, falling back to /config/git.yaml user.email.
pub author_email: Option<String>,
/// Stage modifications/deletions of tracked files first, not untracked files (default false).
pub all: Option<bool>,
/// Replace HEAD, retaining its parents and, unless overridden, original author (default false).
pub amend: Option<bool>,
/// Permit a tree equal to the first parent's tree, or an empty root tree (default false).
pub allow_empty: Option<bool>,
}
/// Published commit and resulting HEAD.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct CommitResult {
/// Full commit object ID.
pub id: String,
/// HEAD after the checked reference update.
pub head: crate::HeadInfo,
/// Detached-HEAD warning, if applicable.
pub warnings: Vec<String>,
}
fn validate(path: &str) -> Result<()> {
repo::validate_path(path)?;
if path.contains('\\') || path.split('/').any(|s| s.eq_ignore_ascii_case(".git")) {
return Err(error(
"path",
"paths under .git and backslashes are not allowed",
));
}
Ok(())
}
fn validate_paths(paths: &[String]) -> Result<()> {
if paths.is_empty() {
return Err(error("paths", "supply at least one literal path"));
}
for p in paths {
validate(p)?;
}
Ok(())
}
fn matches(path: &str, selected: &[String]) -> bool {
selected
.iter()
.any(|p| path == p || path.strip_prefix(p).is_some_and(|s| s.starts_with('/')))
}
fn meta(path: &Path) -> Result<Option<fs::Metadata>> {
match fs::symlink_metadata(path) {
Ok(m) => Ok(Some(m)),
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
Ok(None)
}
Err(e) => Err(error("stage stat", e)),
}
}
// Do not traverse symlink parents even when an explicit path points outside via a link.
fn safe(root: &Path, path: &str) -> Result<()> {
validate(path)?;
for parent in Path::new(path)
.ancestors()
.skip(1)
.filter(|p| !p.as_os_str().is_empty())
{
if meta(&root.join(parent))?.is_some_and(|m| m.file_type().is_symlink()) {
return Err(error("path", format!("symlink parent: {path}")));
}
}
Ok(())
}
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());
for e in index.entries() {
let p = e
.path(&index)
.to_str()
.map_err(|e| error("index path UTF-8", e))?;
validate(p)?;
if e.mode == Mode::DIR || e.flags.contains(Flags::SKIP_WORKTREE) {
return Err(error("stage", "sparse indexes are unsupported"));
}
state.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, p.as_bytes().as_bstr());
}
state.sort_entries();
Ok(state)
}
struct PendingIndex {
lock: std::path::PathBuf,
dest: std::path::PathBuf,
published: bool,
}
impl Drop for PendingIndex {
fn drop(&mut self) {
if !self.published {
let _ = fs::remove_file(&self.lock);
}
}
}
impl PendingIndex {
fn commit(mut self) -> Result<()> {
fs::rename(&self.lock, &self.dest).map_err(|e| error("stage index rename", e))?;
self.published = true;
Ok(())
}
}
fn prepare(repo: &gix::Repository, mut state: State) -> Result<PendingIndex> {
use std::io::Write;
state.sort_entries();
state.remove_tree();
let dest = repo.git_dir().join("index");
let lock = repo.git_dir().join("index.lock");
// Do not create the cleanup guard until create_new succeeds: an existing lock is not ours.
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock)
.map_err(|e| error("stage index lock", e))?;
let pending = PendingIndex {
lock,
dest: dest.clone(),
published: false,
};
gix::index::File::from_state(state, dest)
.write_to(&mut file, Default::default())
.map_err(|e| error("stage index write", e))?;
file.flush().map_err(|e| error("stage index flush", e))?;
drop(file);
Ok(pending)
}
pub(crate) fn publish(repo: &gix::Repository, state: State) -> Result<()> {
prepare(repo, state)?.commit()
}
fn head_state(repo: &gix::Repository) -> Result<State> {
if repo.head().map_err(|e| error("stage HEAD", e))?.is_unborn() {
return Ok(State::new(repo.object_hash()));
}
let tree = repo
.head_commit()
.map_err(|e| error("stage HEAD", e))?
.tree_id()
.map_err(|e| error("stage tree", e))?
.detach();
State::from_tree(&tree, &repo.objects, Default::default()).map_err(|e| error("stage tree", e))
}
fn work_entry(
repo: &gix::Repository,
path: &str,
previous: Option<Mode>,
) -> Result<Option<(Stat, gix::ObjectId, Mode)>> {
let root = repo.workdir().ok_or_else(|| error("add", "no worktree"))?;
safe(root, path)?;
let file = root.join(path);
let Some(m) = meta(&file)? else {
return Ok(None);
};
if m.is_dir() {
return Ok(None);
}
let (bytes, mode) = if m.file_type().is_symlink() {
let target = fs::read_link(&file).map_err(|e| error("add symlink", e))?;
(
gix::path::into_bstr(target)
.map_err(|e| error("add symlink", e))?
.into_owned()
.to_vec(),
Mode::SYMLINK,
)
} else if m.is_file() {
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
if m.permissions().mode() & 0o111 != 0 {
Mode::FILE_EXECUTABLE
} else {
Mode::FILE
}
};
#[cfg(not(unix))]
let mode = if previous == Some(Mode::FILE_EXECUTABLE) {
Mode::FILE_EXECUTABLE
} else {
Mode::FILE
};
let _ = previous;
(fs::read(&file).map_err(|e| error("add read", e))?, mode)
} else {
return Err(error(
"add",
format!("not a regular file or symlink: {path}"),
));
};
let stat =
gix::index::fs::Metadata::from_path_no_follow(&file).map_err(|e| error("add stat", e))?;
let stat = Stat::from_fs(&stat).map_err(|e| error("add stat", e))?;
let id = repo
.write_blob(&bytes)
.map_err(|e| error("add blob", e))?
.detach();
Ok(Some((stat, id, mode)))
}
fn stage(repo: &gix::Repository, state: State, selected: BTreeSet<String>) -> Result<State> {
// Group conflict stages too; keep unrelated entries verbatim. Avoid scanning and
// compacting the entire index for each selected path (quadratic for add-all).
let mut entries = BTreeMap::<String, Vec<_>>::new();
for e in state.entries() {
let path = e
.path(&state)
.to_str()
.map_err(|e| error("index path UTF-8", e))?
.to_owned();
entries
.entry(path)
.or_default()
.push((e.stat, e.id, e.flags, e.mode));
}
for path in selected {
let previous = entries.get(&path).and_then(|es| es.first()).map(|e| e.3);
if previous == Some(Mode::COMMIT) {
return Err(error("add", "staging submodules is unsupported"));
}
let entry = work_entry(repo, &path, previous)?;
entries.remove(&path);
if let Some((stat, id, mode)) = entry {
// Resolve file/directory replacements using a prefix range and ancestor
// lookups, not two full-index passes. Never implicitly remove a gitlink.
let prefix = format!("{path}/");
let mut collisions: Vec<_> = entries
.range(prefix.clone()..)
.take_while(|(p, _)| p.starts_with(&prefix))
.map(|(p, _)| p.clone())
.collect();
collisions.extend(
Path::new(&path)
.ancestors()
.skip(1)
.filter_map(Path::to_str)
.filter(|p| entries.contains_key(*p))
.map(str::to_owned),
);
for p in collisions {
if entries
.get(&p)
.is_some_and(|es| es.iter().any(|e| e.3 == Mode::COMMIT))
{
return Err(error(
"add",
"file/directory replacement would remove a submodule",
));
}
entries.remove(&p);
}
entries.insert(path, vec![(stat, id, Flags::empty(), mode)]);
}
}
let mut state = State::new(repo.object_hash());
for (path, es) in entries {
for (stat, id, flags, mode) in es {
state.dangerously_push_entry(stat, id, flags, mode, path.as_bytes().as_bstr());
}
}
state.sort_entries();
Ok(state)
}
pub(crate) fn add(repo: &gix::Repository, p: AddParams) -> Result<StagedPaths> {
if !p.all.unwrap_or(false) {
validate_paths(&p.paths)?;
}
for path in &p.paths {
validate(path)?;
}
let root = repo.workdir().ok_or_else(|| error("add", "no worktree"))?;
let state = load(repo)?;
let tracked: BTreeSet<_> = state
.entries()
.iter()
.filter(|e| e.mode != Mode::COMMIT)
.map(|e| e.path(&state).to_str_lossy().into_owned())
.collect();
let gitlinks: BTreeSet<_> = state
.entries()
.iter()
.filter(|e| e.mode == Mode::COMMIT)
.map(|e| e.path(&state).to_str_lossy().into_owned())
.collect();
// Directory expansion must use the ignore-aware walker, just like add-all.
// A directly named file remains force-addable even when ignored.
let needs_walk = p.all.unwrap_or(false) || p.paths.iter().any(|p| root.join(p).is_dir());
let untracked: BTreeSet<_> = if needs_walk {
crate::changes::status(repo)?
.untracked
.into_iter()
.collect()
} else {
BTreeSet::new()
};
let mut selected = BTreeSet::new();
if p.all.unwrap_or(false) {
selected.extend(tracked.iter().cloned());
selected.extend(untracked.iter().filter(|p| !gitlinks.contains(*p)).cloned());
}
for path in &p.paths {
safe(root, path)?;
if gitlinks.contains(path) {
return Err(error("add", "staging submodules is unsupported"));
}
selected.extend(
tracked
.iter()
.filter(|s| matches(s, std::slice::from_ref(path)))
.cloned(),
);
match meta(&root.join(path))? {
Some(m) if m.is_dir() => {
if root.join(path).join(".git").exists() {
return Err(error("add", "nested repositories are unsupported"));
}
selected.extend(
untracked
.iter()
.filter(|s| {
matches(s, std::slice::from_ref(path)) && !gitlinks.contains(*s)
})
.cloned(),
);
}
Some(_) => {
selected.insert(path.clone());
}
None => {
if !tracked
.iter()
.chain(&gitlinks)
.any(|s| matches(s, std::slice::from_ref(path)))
{
return Err(error(
"add",
format!("path does not exist and is not tracked: {path}"),
));
}
}
}
}
let paths = selected.iter().cloned().collect();
publish(repo, stage(repo, state, selected)?)?;
Ok(StagedPaths { paths })
}
pub(crate) fn unstage(repo: &gix::Repository, p: PathsParams) -> Result<StagedPaths> {
validate_paths(&p.paths)?;
let mut state = load(repo)?;
let head = head_state(repo)?;
let mut selected = BTreeSet::new();
for s in [&state, &head] {
selected.extend(
s.entries()
.iter()
.map(|e| e.path(s).to_str_lossy().into_owned())
.filter(|s| matches(s, &p.paths)),
);
}
state.remove_entries(|_, path, _| selected.contains(path.to_str().unwrap_or("")));
for e in head.entries() {
let path = e.path(&head);
if selected.contains(path.to_str().unwrap_or("")) {
state.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, path);
}
}
// A partial reset can otherwise create a D/F conflict with unrelated staging.
check_structure(&state)?;
publish(repo, state)?;
Ok(StagedPaths {
paths: selected.into_iter().collect(),
})
}
fn check_structure(state: &State) -> Result<()> {
let paths: BTreeSet<_> = state
.entries()
.iter()
.map(|e| e.path(state).to_str_lossy().into_owned())
.collect();
for path in &paths {
for parent in Path::new(path).ancestors().skip(1).filter_map(Path::to_str) {
if paths.contains(parent) {
return Err(error("index", "file/directory conflict; select both paths"));
}
}
}
Ok(())
}
pub(crate) fn rm(repo: &gix::Repository, p: RmParams) -> Result<StagedPaths> {
validate_paths(&p.paths)?;
let mut state = load(repo)?;
let root = repo.workdir().ok_or_else(|| error("rm", "no worktree"))?;
let selected: BTreeSet<_> = state
.entries()
.iter()
.map(|e| e.path(&state).to_str_lossy().into_owned())
.filter(|s| matches(s, &p.paths))
.collect();
for path in &p.paths {
if !selected
.iter()
.any(|s| matches(s, std::slice::from_ref(path)))
{
return Err(error("rm", format!("path is not tracked: {path}")));
}
}
if !p.keep_file.unwrap_or(false) {
let head = head_state(repo)?;
// Preflight every path before deleting any file. Do not delete staged or local-only work.
for path in &selected {
safe(root, path)?;
for e in state
.entries()
.iter()
.filter(|e| e.path(&state) == path.as_bytes().as_bstr())
{
if e.stage_raw() != 0
|| !head.entries().iter().any(|h| {
h.path(&head) == e.path(&state) && h.id == e.id && h.mode == e.mode
})
{
return Err(error(
"rm",
format!("staged changes: {path}; use keep_file to preserve bytes"),
));
}
if let Some((_, id, mode)) = work_entry(repo, path, Some(e.mode))? {
if id != e.id || mode != e.mode {
return Err(error("rm", format!("local changes: {path}; use keep_file")));
}
} else if meta(&root.join(path))?.is_some() {
return Err(error("rm", format!("obstructing directory: {path}")));
}
if !matches!(e.mode, Mode::FILE | Mode::FILE_EXECUTABLE | Mode::SYMLINK) {
return Err(error("rm", "submodule removal unsupported"));
}
}
}
}
state.remove_entries(|_, path, _| selected.contains(path.to_str().unwrap_or("")));
// Reserve and fully serialize index.lock before any destructive worktree operation.
let pending = prepare(repo, state)?;
if !p.keep_file.unwrap_or(false) {
for path in &selected {
if meta(&root.join(path))?.is_some() {
fs::remove_file(root.join(path)).map_err(|e| error("rm file", e))?;
}
}
}
pending.commit()?;
Ok(StagedPaths {
paths: selected.into_iter().collect(),
})
}
pub(crate) fn commit(repo: &gix::Repository, p: CommitParams) -> Result<CommitResult> {
if p.message.trim().is_empty() || p.message.contains('\0') {
return Err(error(
"commit",
"message must be nonblank and contain no NUL",
));
}
if repo.git_dir().join("rebase-merge").exists() || repo.git_dir().join("rebase-apply").exists()
{
return Err(error(
"commit",
"rebase in progress; use rebase_continue after staging",
));
}
let override_author = p.author_name.is_some() || p.author_email.is_some();
let sig = signature(p.author_name, p.author_email)?;
let merge_parent = crate::merge::pending_parent(repo)?;
if merge_parent.is_some() && p.amend.unwrap_or(false) {
return Err(error("commit", "cannot amend during a merge"));
}
let head = repo.head().map_err(|e| error("commit HEAD", e))?;
let old = head.id().map(|id| id.detach());
let amend = p.amend.unwrap_or(false);
if amend && old.is_none() {
return Err(error("commit amend", "HEAD is unborn"));
}
let previous = old
.map(|id| repo.find_commit(id).map_err(|e| error("commit HEAD", e)))
.transpose()?;
let mut parents: Vec<_> = if amend {
previous
.as_ref()
.into_iter()
.flat_map(|c| c.parent_ids().map(|id| id.detach()))
.collect()
} else {
old.into_iter().collect()
};
if let Some(id) = merge_parent {
parents.push(id);
}
let mut state = load(repo)?;
if state
.entries()
.iter()
.any(|e| e.stage_raw() != 0 || e.flags.contains(Flags::INTENT_TO_ADD))
{
return Err(error(
"commit",
"unmerged or intent-to-add index; resolve/stage paths first",
));
}
if p.all.unwrap_or(false) {
let selected = state
.entries()
.iter()
.filter(|e| e.mode != Mode::COMMIT)
.map(|e| e.path(&state).to_str_lossy().into_owned())
.collect();
state = stage(repo, state, selected)?;
}
let tree = tree_from_index(repo, &state)?;
let empty = gix::ObjectId::empty_tree(repo.object_hash());
let base = if let Some(id) = parents.first() {
repo.find_commit(*id)
.map_err(|e| error("commit parent", e))?
.tree_id()
.map_err(|e| error("commit parent tree", e))?
.detach()
} else {
empty
};
if tree == base && merge_parent.is_none() && !p.allow_empty.unwrap_or(false) {
return Err(error(
"commit",
"empty commit refused; set allow_empty=true",
));
}
let author = if amend && !override_author {
previous
.as_ref()
.ok_or_else(|| error("amend", "missing HEAD"))?
.decode()
.map_err(|e| error("amend author", e))?
.author()
.map_err(|e| error("amend author", e))?
.to_owned()
.map_err(|e| error("amend author", e))?
} else {
sig.clone()
};
let object = gix::objs::Commit {
tree,
parents: parents.into_iter().collect(),
author,
committer: sig.clone(),
encoding: None,
message: p.message.clone().into(),
extra_headers: vec![],
};
let id = repo
.write_object(&object)
.map_err(|e| error("commit object", e))?
.detach();
if p.all.unwrap_or(false) {
publish(repo, state)?;
}
update_head(
repo,
old,
id,
&sig,
&format!(
"commit{}: {}",
if amend {
" (amend)"
} else if old.is_none() {
" (initial)"
} else {
""
},
p.message.lines().next().unwrap_or("").replace('\r', "")
),
)?;
if merge_parent.is_some() {
crate::merge::clear(repo)?;
}
Ok(CommitResult {
id: id.to_string(),
head: crate::head_info(repo)?,
warnings: if head.is_detached() {
vec!["commit created on detached HEAD; no branch was advanced".into()]
} else {
vec![]
},
})
}
/// Serialize the resolved index without updating any reference (also used by rebase).
pub(crate) fn tree_from_index(repo: &gix::Repository, state: &State) -> Result<gix::ObjectId> {
if state
.entries()
.iter()
.any(|e| e.stage_raw() != 0 || e.flags.contains(Flags::INTENT_TO_ADD))
{
return Err(error(
"commit",
"unmerged or intent-to-add index; resolve/stage paths first",
));
}
check_structure(state)?;
let empty = repo
.write_object(&gix::objs::Tree { entries: vec![] })
.map_err(|e| error("commit empty tree", e))?
.detach();
let tree = repo.find_tree(empty).map_err(|e| error("commit tree", e))?;
let mut editor =
gix::object::tree::Editor::new(&tree).map_err(|e| error("commit tree editor", e))?;
for e in state.entries() {
use gix::objs::tree::EntryKind;
let kind = match e.mode {
Mode::FILE => EntryKind::Blob,
Mode::FILE_EXECUTABLE => EntryKind::BlobExecutable,
Mode::SYMLINK => EntryKind::Link,
Mode::COMMIT => EntryKind::Commit,
_ => return Err(error("commit", "unsupported index mode")),
};
editor
.upsert(e.path(&state), kind, e.id)
.map_err(|e| error("commit tree entry", e))?;
}
let tree = editor
.write()
.map_err(|e| error("commit tree write", e))?
.detach();
Ok(tree)
}
pub(crate) fn signature(
name: Option<String>,
email: Option<String>,
) -> Result<gix::actor::Signature> {
let identity = crate::identity(name, email)?;
let name = identity
.name
.ok_or_else(|| error("identity", "missing name"))?;
let email = identity
.email
.ok_or_else(|| error("identity", "missing email"))?;
if [&name, &email]
.iter()
.any(|s| s.contains(['\n', '\r', '<', '>', '\0']))
{
return Err(error(
"identity",
"name/email must not contain newlines, NUL or angle brackets",
));
}
let seconds = std::env::var("GIT_AUTHOR_DATE")
.ok()
.and_then(|s| {
s.split_whitespace()
.next()?
.trim_start_matches("@")
.parse()
.ok()
})
.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
});
Ok(gix::actor::Signature {
name: name.into(),
email: email.into(),
time: gix::date::Time::new(seconds, 0),
})
}
// Separate from parent selection: amend must compare against the replaced commit, not its parent.
pub(crate) fn update_head(
repo: &gix::Repository,
old: Option<gix::ObjectId>,
new: gix::ObjectId,
sig: &gix::actor::Signature,
message: &str,
) -> Result<()> {
use gix::refs::{
Target,
transaction::{Change, LogChange, PreviousValue, RefEdit},
};
let mut time = Default::default();
repo.edit_references_as(
[RefEdit {
name: "HEAD"
.try_into()
.map_err(|e| error("commit HEAD name", e))?,
deref: true,
change: Change::Update {
expected: old
.map(|id| PreviousValue::MustExistAndMatch(Target::Object(id)))
.unwrap_or(PreviousValue::MustNotExist),
new: Target::Object(new),
log: LogChange {
message: message.into(),
force_create_reflog: true,
..Default::default()
},
},
}],
Some(sig.to_ref(&mut time)),
)
.map_err(|e| error("commit reference compare-and-swap", e))?;
Ok(())
}
+447
View File
@@ -0,0 +1,447 @@
//! Linked worktrees use ordinary Git files. Callers hold only the common-dir lock.
use crate::Result;
use crate::{
branches,
repo::{self, error},
};
use std::{
collections::BTreeSet,
fs,
io::Write,
path::{Path, PathBuf},
};
/// Attach a linked worktree, or adopt existing bytes onto an unborn branch.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct AddParams {
/// Repository name or absolute worktree/bare path under /repos/ or /plugins/.
pub repo: String,
/// Absolute worktree path under /plugins/ or /repos/, without dot segments.
pub path: String,
/// Short local branch name; an existing branch must be checked out nowhere.
pub branch: String,
/// Starting revision for a new branch, default HEAD. Not used for existing branches; forbidden with adopt.
pub from: Option<String>,
/// Attach an existing directory (including empty) to a new unborn branch; only .git is written.
pub adopt: Option<bool>,
}
/// Delete a linked worktree and its private metadata, never the common repository.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct RemoveParams {
/// Absolute linked-worktree root under /plugins/ or /repos/.
pub repo: String,
/// Discard dirty, untracked and pending operation state, default false.
pub force: Option<bool>,
}
/// Linked worktree identity.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct WorktreeInfo {
/// Absolute worktree root.
pub path: String,
/// Private Git metadata directory.
pub admin: String,
/// Checkout feature warnings, empty for adoption and removal.
pub warnings: Vec<String>,
}
fn canonical(path: &Path) -> Result<PathBuf> {
fs::canonicalize(path).map_err(|e| error("worktree path", format!("{}: {e}", path.display())))
}
fn read(path: &Path) -> Result<String> {
fs::read_to_string(path)
.map_err(|e| error("worktree metadata", format!("{}: {e}", path.display())))
}
fn main_worktree(common: &Path) -> Result<Option<PathBuf>> {
let main = gix::open_opts(common, gix::open::Options::default().open_path_as_is(true))
.map_err(|e| error("main worktree", e))?;
Ok(main.workdir().map(Path::to_owned))
}
/// Scan symbolic HEADs, including unborn branches. Ignore only this handle's private HEAD.
/// A bare common HEAD is a default branch, not a checked-out worktree.
pub(crate) fn ensure_available(repo: &gix::Repository, branch: &str) -> Result<()> {
let common = canonical(repo.common_dir())?;
let current = canonical(repo.git_dir())?;
let mut heads = Vec::new();
if let Some(path) = main_worktree(&common)? {
heads.push((common.clone(), path));
}
let entries = match fs::read_dir(common.join("worktrees")) {
Ok(entries) => Some(entries),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(error("worktree scan", e)),
};
if let Some(entries) = entries {
for entry in entries {
let entry = entry.map_err(|e| error("worktree scan", e))?;
if !entry
.file_type()
.map_err(|e| error("worktree scan", e))?
.is_dir()
{
continue;
}
let admin = entry.path();
let gitfile = read(&admin.join("gitdir"))?;
let gitfile = PathBuf::from(gitfile.trim());
let path = gitfile
.parent()
.ok_or_else(|| error("worktree gitdir", "missing worktree path"))?
.to_owned();
heads.push((admin, path));
}
}
for (admin, path) in heads {
if canonical(&admin)? == current {
continue;
}
let head = read(&admin.join("HEAD"))?;
if head.trim().strip_prefix("ref: ") == Some(branch) {
return Err(error(
"branch checked out",
format!("{branch} is checked out at {}", path.display()),
));
}
}
Ok(())
}
fn absolute(path: &str) -> Result<PathBuf> {
if !path.starts_with('/') || path.contains(['\r', '\n']) {
return Err(error(
"worktree path",
"expected an absolute path without line breaks",
));
}
repo::repo_path(path)
}
fn separate(path: &Path, common: &Path) -> Result<()> {
if path.starts_with(common) || common.starts_with(path) {
return Err(error(
"worktree path",
"worktree and common repository must not contain one another",
));
}
Ok(())
}
fn write_new(path: &Path, bytes: &str) -> Result<()> {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|e| error("worktree create", format!("{}: {e}", path.display())))?;
if let Err(e) = file.write_all(bytes.as_bytes()) {
drop(file);
fs::remove_file(path).map_err(|cleanup| error("worktree write cleanup", cleanup))?;
return Err(error("worktree write", e));
}
Ok(())
}
/// Attach existing bytes to an existing branch without checking out any files.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct AttachParams {
/// Bare common repository path.
pub repo: String,
/// Existing worktree directory; only .git metadata is written.
pub path: String,
/// Existing short local branch name.
pub branch: String,
}
pub(crate) fn attach(repo: &gix::Repository, p: AttachParams) -> Result<WorktreeInfo> {
add_with(
repo,
AddParams {
repo: p.repo,
path: p.path,
branch: p.branch,
from: None,
adopt: None,
},
true,
)
}
pub(crate) fn add(repo: &gix::Repository, p: AddParams) -> Result<WorktreeInfo> {
add_with(repo, p, false)
}
fn add_with(repo: &gix::Repository, p: AddParams, keep_files: bool) -> Result<WorktreeInfo> {
let path = absolute(&p.path)?;
let common = canonical(repo.common_dir())?;
separate(&path, &common)?;
let branch = branches::branch_name(&p.branch)?;
ensure_available(repo, &branch)?;
if let Some(current_path) = repo.workdir() {
if 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 adopt = p.adopt.unwrap_or(false);
if adopt && (exists || p.from.is_some()) {
return Err(error(
"worktree adopt",
"requires a new unborn branch and no from revision",
));
}
let existed = path.try_exists().map_err(|e| error("worktree path", e))?;
if existed && !path.is_dir() {
return Err(error("worktree path", "not a directory"));
}
let nonempty = if existed {
fs::read_dir(&path)
.map_err(|e| error("worktree directory", e))?
.next()
.transpose()
.map_err(|e| error("worktree directory", e))?
.is_some()
} else {
false
};
if adopt && !existed {
return Err(error("worktree adopt", "requires an existing directory"));
}
if keep_files && !exists {
return Err(error("worktree attach", "requires an existing branch"));
}
if keep_files && !existed {
return Err(error("worktree attach", "requires an existing directory"));
}
if !adopt && !keep_files && nonempty {
return Err(error(
"worktree path",
format!("{} is nonempty", path.display()),
));
}
if path.join(".git").exists() {
return Err(error("worktree adopt", ".git already exists"));
}
let id_path = if path.file_name().is_some_and(|s| s == "source") {
path.parent().unwrap_or(&path)
} else {
&path
};
let id = id_path
.file_name()
.ok_or_else(|| error("worktree id", "missing folder name"))?;
let admin = common.join("worktrees").join(id);
// Resolve before any mutation. Existing branches ignore from, matching checkout semantics.
let start = if adopt {
None
} else {
Some(branches::resolve(
repo,
if exists {
&branch
} else {
p.from.as_deref().unwrap_or("HEAD")
},
)?)
};
fs::create_dir_all(common.join("worktrees")).map_err(|e| error("worktree admin", e))?;
fs::create_dir(&admin)
.map_err(|e| error("worktree id clash", format!("{}: {e}", admin.display())))?;
let mut gitfile_created = false;
let result: Result<WorktreeInfo> = (|| {
fs::create_dir_all(&path).map_err(|e| error("worktree mkdir", e))?;
separate(&canonical(&path)?, &common)?;
// Start detached to let checkout create/attach the branch after index publication.
fs::write(
admin.join("HEAD"),
start
.map(|id| format!("{id}\n"))
.unwrap_or_else(|| format!("ref: {branch}\n")),
)
.map_err(|e| error("worktree HEAD", e))?;
fs::write(admin.join("commondir"), "../..\n")
.map_err(|e| error("worktree commondir", e))?;
fs::write(admin.join("gitdir"), format!("{}/.git\n", path.display()))
.map_err(|e| error("worktree gitdir", e))?;
write_new(
&path.join(".git"),
&format!("gitdir: {}\n", admin.display()),
)?;
gitfile_created = true;
let linked = repo::open(&p.path)?;
let warnings = if let Some(id) = start {
let tree = linked
.find_commit(id)
.map_err(|e| error("worktree commit", e))?
.tree_id()
.map_err(|e| error("worktree tree", e))?
.detach();
if keep_files {
let state =
gix::index::State::from_tree(&tree, &linked.objects, Default::default())
.map_err(|e| error("worktree attach index", e))?;
crate::staging::publish(&linked, state)?;
fs::write(admin.join("HEAD"), format!("ref: {branch}\n"))
.map_err(|e| error("worktree attach HEAD", e))?;
return Ok(WorktreeInfo {
path: p.path.clone(),
admin: admin.to_string_lossy().into_owned(),
warnings: vec![],
});
}
let mut warnings = crate::checkout::write_tree_to_worktree(&linked, None, tree, false)?;
warnings.extend(
branches::checkout(
&linked,
branches::CheckoutParams {
repo: p.path.clone(),
target: p.branch.clone(),
create: Some(!exists),
force: None,
},
)?
.warnings,
);
warnings
} else {
Vec::new()
};
Ok(WorktreeInfo {
path: p.path.clone(),
admin: admin.to_string_lossy().into_owned(),
warnings,
})
})();
if result.is_err() {
// Adoption never removes source bytes. Non-adoption started empty, so all leaves are ours.
let cleanup = (|| -> std::io::Result<()> {
if adopt || keep_files {
if gitfile_created {
fs::remove_file(path.join(".git"))?;
}
} else if gitfile_created {
fs::remove_dir_all(&path)?;
if existed {
fs::create_dir(&path)?;
}
} else if !existed && path.is_dir() {
fs::remove_dir(&path)?;
}
Ok(())
})();
let admin_cleanup = fs::remove_dir_all(&admin);
if let Err(e) = cleanup.and(admin_cleanup) {
return Err(error(
"worktree rollback",
format!("{}; original error: {}", e, result.err().unwrap().message),
));
}
}
result
}
pub(crate) fn remove(repo: &gix::Repository, p: RemoveParams) -> Result<WorktreeInfo> {
let path = canonical(&absolute(&p.repo)?)?;
let common = canonical(repo.common_dir())?;
let admin = canonical(repo.git_dir())?;
separate(&path, &common)?;
// Only linked metadata directly under common/worktrees is removable, even with force.
if admin.parent() != Some(common.join("worktrees").as_path()) || !path.join(".git").is_file() {
return Err(error(
"worktree remove",
"cannot remove the original/main repository worktree",
));
}
let backlink = PathBuf::from(read(&admin.join("gitdir"))?.trim());
if canonical(&backlink)? != canonical(&path.join(".git"))? {
return Err(error(
"worktree remove",
"admin gitdir does not point back to this worktree",
));
}
if !p.force.unwrap_or(false) {
let s = crate::changes::status(repo)?;
let mut paths: BTreeSet<_> = s
.staged
.into_iter()
.chain(s.unstaged)
.flat_map(|c| std::iter::once(c.path).chain(c.old_path))
.chain(s.untracked)
.chain(s.conflicted)
.collect();
if let Err(e) = crate::merge::ensure_idle(repo) {
paths.insert(e.message);
}
if repo.git_dir().join("index.lock").exists() {
paths.insert("index.lock".into());
}
// Include ignored untracked files too: removing a worktree must not silently lose them.
let index = repo
.index_or_empty()
.map_err(|e| error("worktree index", e))?;
let tracked: BTreeSet<_> = index
.entries()
.iter()
.map(|e| e.path(&index).to_vec())
.collect();
collect_untracked(&path, &path, &tracked, &mut paths)?;
if !paths.is_empty() {
return Err(error(
"worktree remove dirty/untracked/pending paths",
paths.into_iter().collect::<Vec<_>>().join(", "),
));
}
}
fs::remove_dir_all(&path).map_err(|e| error("worktree remove files", e))?;
fs::remove_dir_all(&admin).map_err(|e| error("worktree remove admin", e))?;
if let Some(parent) = path.parent() {
if parent.parent() == Some(Path::new("/plugins"))
&& parent
.file_name()
.is_some_and(|s| s.to_string_lossy().starts_with("wt-"))
{
match fs::remove_dir(parent) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => {}
Err(e) => return Err(error("worktree remove empty parent", e)),
}
}
}
Ok(WorktreeInfo {
path: p.repo,
admin: admin.to_string_lossy().into_owned(),
warnings: vec![],
})
}
fn collect_untracked(
root: &Path,
dir: &Path,
tracked: &BTreeSet<Vec<u8>>,
paths: &mut BTreeSet<String>,
) -> Result<()> {
for entry in fs::read_dir(dir).map_err(|e| error("worktree directory", e))? {
let entry = entry.map_err(|e| error("worktree directory", e))?;
let path = entry.path();
if path == root.join(".git") {
continue;
}
if entry
.file_type()
.map_err(|e| error("worktree directory", e))?
.is_dir()
{
collect_untracked(root, &path, tracked, paths)?;
} else {
let relative = path
.strip_prefix(root)
.map_err(|e| error("worktree path", e))?
.to_string_lossy();
if !tracked.contains(relative.as_bytes()) {
paths.insert(relative.into_owned());
}
}
}
Ok(())
}