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:
@@ -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 })
|
||||
}
|
||||
@@ -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\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()) })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¤t)?)
|
||||
.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(¤t)?)?,
|
||||
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, ¤t, &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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user