use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use crate::git::{Ended, ObjectId, Output};
pub(crate) fn reads(args: &[&str]) -> bool {
Shape::of(args).is_some()
}
const OBJECT_LIMIT: usize = 512;
thread_local! {
static UNSPENT: std::cell::Cell<usize> = const { std::cell::Cell::new(usize::MAX) };
}
fn spend() -> Option<()> {
UNSPENT.with(|unspent| {
let left = unspent.get();
(left > 0).then(|| unspent.set(left - 1))
})
}
pub(crate) fn answer(args: &[&str], repo: &Path, borrowing: Option<&Path>) -> Option<Output> {
let shape = Shape::of(args)?;
UNSPENT.with(|unspent| unspent.set(OBJECT_LIMIT));
let answered = within_allowance(&shape, repo, borrowing);
let spent = UNSPENT.with(|unspent| unspent.replace(usize::MAX)) == 0;
answered.filter(|_| !spent)
}
fn within_allowance(shape: &Shape<'_>, repo: &Path, borrowing: Option<&Path>) -> Option<Output> {
let view = graph::View::of(repo, borrowing);
if let Some(answered) = shape.walked(&view) {
return Some(answered);
}
if UNSPENT.with(std::cell::Cell::get) == 0 {
return None;
}
if matches!(
shape,
Shape::Count(..) | Shape::Listed(..) | Shape::Messages(..)
) {
return None;
}
crate::native_refs::with_objects_holding(repo, borrowing, &shape.named(), |repository| {
shape.answer(repository)
})
}
pub(crate) fn is_ancestor(
repo: &Path,
borrowing: Option<&Path>,
ancestor: git2::Oid,
descendant: git2::Oid,
) -> Option<bool> {
graph::View::of(repo, borrowing).is_ancestor(ancestor, descendant)
}
pub(crate) fn clear() {
graph::clear();
}
enum Shape<'a> {
MergeBase(git2::Oid, git2::Oid),
IsAncestor(git2::Oid, git2::Oid),
Count(git2::Oid, Vec<git2::Oid>),
Listed(git2::Oid, git2::Oid),
Messages(git2::Oid, git2::Oid),
FirstParents(git2::Oid),
CommittedAt(git2::Oid),
Message(git2::Oid),
Peeled(Vec<Peel>),
Exists(git2::Oid),
IsCommit(git2::Oid),
Names(git2::Oid, git2::Oid, Vec<&'a [u8]>),
Differs(git2::Oid, git2::Oid, Vec<&'a [u8]>),
}
enum Peel {
Tree(git2::Oid),
Commit(git2::Oid),
FirstParent(git2::Oid),
}
fn oid(name: &str) -> Option<git2::Oid> {
let id = ObjectId::parse(name)?;
(id.as_str().len() == 40)
.then(|| git2::Oid::from_str(id.as_str()).ok())
.flatten()
}
fn range(spec: &str) -> Option<(git2::Oid, git2::Oid)> {
let (from, to) = spec.split_once("..")?;
Some((oid(from)?, oid(to)?))
}
fn literal_paths<'a>(specs: &[&'a str]) -> Option<Vec<&'a [u8]>> {
specs
.iter()
.map(|spec| {
let path = spec.strip_prefix(":(literal)")?;
let plain = !path.is_empty()
&& path.is_ascii()
&& !path.starts_with('/')
&& !path.ends_with('/')
&& !path.bytes().any(|byte| byte.is_ascii_control())
&& path
.split('/')
.all(|part| !part.is_empty() && part != "." && part != "..");
plain.then_some(path.as_bytes())
})
.collect()
}
impl<'a> Shape<'a> {
fn named(&self) -> Vec<git2::Oid> {
match self {
Self::Count(a, hidden) => std::iter::once(*a).chain(hidden.iter().copied()).collect(),
Self::MergeBase(a, b)
| Self::IsAncestor(a, b)
| Self::Listed(a, b)
| Self::Messages(a, b)
| Self::Names(a, b, _)
| Self::Differs(a, b, _) => vec![*a, *b],
Self::FirstParents(x)
| Self::CommittedAt(x)
| Self::Message(x)
| Self::Exists(x)
| Self::IsCommit(x) => vec![*x],
Self::Peeled(revisions) => revisions
.iter()
.map(|revision| match revision {
Peel::Tree(id) | Peel::Commit(id) | Peel::FirstParent(id) => *id,
})
.collect(),
}
}
fn of(args: &[&'a str]) -> Option<Self> {
Some(match args {
["merge-base", a, b] => Self::MergeBase(oid(a)?, oid(b)?),
["merge-base", "--is-ancestor", a, b] => Self::IsAncestor(oid(a)?, oid(b)?),
["rev-list", "--count", a, "--not", hidden @ .., "--"] if !hidden.is_empty() => {
Self::Count(
oid(a)?,
hidden.iter().map(|id| oid(id)).collect::<Option<_>>()?,
)
}
["rev-list", "--reverse", spec, "--"] => {
let (from, to) = range(spec)?;
Self::Listed(from, to)
}
["log", "--reverse", "--format=%H%x00%B%x00%x1e", spec, "--"] => {
let (from, to) = range(spec)?;
Self::Messages(from, to)
}
["log", "--first-parent", "-n65", "--format=%H%x00%T", x, "--"] => {
Self::FirstParents(oid(x)?)
}
["log", "-1", "--format=%ct", x, "--"] => Self::CommittedAt(oid(x)?),
["log", "-1", "--format=%B", x, "--"] => Self::Message(oid(x)?),
["rev-parse", "--verify", revision] => Self::Peeled(vec![peel(revision)?]),
["rev-parse", first, second]
if first.ends_with("^{tree}") && second.ends_with("^{tree}") =>
{
Self::Peeled(vec![peel(first)?, peel(second)?])
}
["cat-file", "-e", x] => match x.strip_suffix("^{commit}") {
Some(x) => Self::IsCommit(oid(x)?),
None => Self::Exists(oid(x)?),
},
["diff", "--name-only", "--no-renames", "-z", a, b, "--", paths @ ..] => {
Self::Names(oid(a)?, oid(b)?, literal_paths(paths)?)
}
["diff", "--quiet", a, b, "--", paths @ ..] => {
Self::Differs(oid(a)?, oid(b)?, literal_paths(paths)?)
}
_ => return None,
})
}
fn answer(&self, repository: &git2::Repository) -> Option<Output> {
match self {
Self::MergeBase(a, b) => {
let (a, b) = (commit(repository, *a)?.id(), commit(repository, *b)?.id());
let bases = repository.merge_bases(a, b).ok()?;
match bases.iter().collect::<Vec<_>>().as_slice() {
[only] => printed(format!("{only}\n")),
_ => None,
}
}
Self::IsAncestor(a, b) => {
let (a, b) = (commit(repository, *a)?.id(), commit(repository, *b)?.id());
let ancestor = a == b || repository.graph_descendant_of(b, a).ok()?;
Some(exited(if ancestor { 0 } else { 1 }, String::new()))
}
Self::Count(a, hidden) => {
let mut walk = repository.revwalk().ok()?;
walk.push(commit(repository, *a)?.id()).ok()?;
for id in hidden {
walk.hide(commit(repository, *id)?.id()).ok()?;
}
let mut count = 0_usize;
for listed in walk {
listed.ok()?;
count += 1;
}
printed(format!("{count}\n"))
}
Self::Listed(from, to) => {
let chain = linear_range(repository, *from, *to)?;
printed(
chain
.iter()
.rev()
.map(|commit| format!("{}\n", commit.id()))
.collect(),
)
}
Self::Messages(from, to) => {
let chain = linear_range(repository, *from, *to)?;
let mut out = Vec::new();
for commit in chain.iter().rev() {
out.extend_from_slice(commit.id().to_string().as_bytes());
out.push(0);
out.extend_from_slice(body(commit)?);
out.extend_from_slice(b"\0\x1e\n");
}
printed_text(out)
}
Self::FirstParents(x) => {
let mut current = commit(repository, *x)?;
let mut out = String::new();
for _ in 0..65 {
out.push_str(&format!("{}\0{}\n", current.id(), current.tree_id()));
if current.parent_count() == 0 {
break;
}
current = current.parent(0).ok()?;
}
printed(out)
}
Self::CommittedAt(x) => printed(format!(
"{}\n",
commit(repository, *x)?.committer().when().seconds()
)),
Self::Message(x) => {
let commit = commit(repository, *x)?;
let mut out = body(&commit)?.to_vec();
out.push(b'\n');
printed_text(out)
}
Self::Peeled(revisions) => {
let mut out = String::new();
for revision in revisions {
let peeled = match revision {
Peel::Tree(id) => tree_of(repository, *id)?,
Peel::Commit(id) => commit(repository, *id)?.id(),
Peel::FirstParent(id) => commit(repository, *id)?.parent_id(0).ok()?,
};
out.push_str(&format!("{peeled}\n"));
}
printed(out)
}
Self::Exists(x) => {
repository.odb().ok()?.read_header(*x).ok()?;
Some(exited(0, String::new()))
}
Self::IsCommit(x) => {
commit(repository, *x)?;
Some(exited(0, String::new()))
}
Self::Names(a, b, paths) => {
let mut out = Vec::new();
for path in changed(repository, *a, *b, paths)? {
out.extend_from_slice(&path);
out.push(0);
}
printed_text(out)
}
Self::Differs(a, b, paths) => {
let differs = !changed(repository, *a, *b, paths)?.is_empty();
Some(exited(if differs { 1 } else { 0 }, String::new()))
}
}
}
}
impl Shape<'_> {
fn walked(&self, view: &graph::View) -> Option<Output> {
match self {
Self::MergeBase(a, b) => printed(format!("{}\n", view.merge_base(*a, *b)?)),
Self::IsAncestor(a, b) => {
let ancestor = view.is_ancestor(*a, *b)?;
Some(exited(if ancestor { 0 } else { 1 }, String::new()))
}
Self::Count(a, hidden) => printed(format!("{}\n", view.count(*a, hidden)?)),
Self::Listed(from, to) => printed(
view.linear_range(*from, *to)?
.iter()
.rev()
.map(|id| format!("{id}\n"))
.collect(),
),
Self::Messages(from, to) => {
let mut out = Vec::new();
for id in view.linear_range(*from, *to)?.iter().rev() {
out.extend_from_slice(id.to_string().as_bytes());
out.push(0);
view.with_commit(*id, |commit| {
out.extend_from_slice(body(commit)?);
Some(())
})?;
out.extend_from_slice(b"\0\x1e\n");
}
printed_text(out)
}
Self::FirstParents(x) => {
let mut current = (*x, view.commit(*x)?);
let mut out = String::new();
for _ in 0..65 {
out.push_str(&format!("{}\0{}\n", current.0, current.1.tree));
let Some(parent) = current.1.parents.first().copied() else {
break;
};
current = (parent, view.commit(parent)?);
}
printed(out)
}
Self::CommittedAt(x) => printed(format!("{}\n", view.commit(*x)?.time)),
Self::Peeled(revisions) => {
let mut out = String::new();
for revision in revisions {
let peeled = match revision {
Peel::Tree(id) => view.commit(*id)?.tree,
Peel::Commit(id) => {
view.commit(*id)?;
*id
}
Peel::FirstParent(id) => *view.commit(*id)?.parents.first()?,
};
out.push_str(&format!("{peeled}\n"));
}
printed(out)
}
Self::IsCommit(x) => {
view.commit(*x)?;
Some(exited(0, String::new()))
}
Self::Message(_) | Self::Exists(_) | Self::Names(..) | Self::Differs(..) => None,
}
}
}
mod graph {
use std::cell::RefCell;
use std::collections::{BinaryHeap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::rc::Rc;
pub(super) struct Parsed {
pub(super) parents: Vec<git2::Oid>,
pub(super) tree: git2::Oid,
pub(super) time: i64,
}
#[derive(Clone, PartialEq, Eq, Hash)]
enum Source {
Store(PathBuf),
Repository(PathBuf, Option<PathBuf>),
}
type Ancestors = Rc<HashSet<git2::Oid>>;
const ANCESTRY: usize = 4096;
thread_local! {
static COMMITS: RefCell<HashMap<(Source, git2::Oid), Rc<Parsed>>> = RefCell::new(HashMap::new());
static ANCESTORS: RefCell<HashMap<(Source, git2::Oid), Option<Ancestors>>> = RefCell::new(HashMap::new());
}
pub(super) fn clear() {
COMMITS.with(|commits| commits.borrow_mut().clear());
ANCESTORS.with(|ancestors| ancestors.borrow_mut().clear());
}
const PARENT1: u8 = 1;
const PARENT2: u8 = 2;
const STALE: u8 = 4;
const RESULT: u8 = 8;
pub(super) struct View {
at: PathBuf,
borrowing: Option<PathBuf>,
shared: Vec<PathBuf>,
}
impl View {
pub(super) fn of(at: &Path, borrowing: Option<&Path>) -> Self {
let borrowing = crate::native_refs::beyond_alternates(at, borrowing);
Self {
at: at.to_owned(),
borrowing: borrowing.map(Path::to_owned),
shared: crate::native_refs::borrowed(at, borrowing),
}
}
fn own(&self) -> Source {
Source::Repository(self.at.clone(), self.borrowing.clone())
}
pub(super) fn with_commit<T>(
&self,
id: git2::Oid,
read: impl FnOnce(&git2::Commit<'_>) -> Option<T>,
) -> Option<T> {
let mut read = Some(read);
for store in &self.shared {
let held = crate::native_refs::with_store(store, |repository| {
let held = repository
.odb()
.ok()?
.exists_ext(id, git2::OdbLookupFlags::NO_REFRESH);
held.then(|| {
super::commit_only(repository, id).and_then(|commit| read.take()?(&commit))
})
});
if let Some(answer) = held {
return answer;
}
}
crate::native_refs::with_objects(&self.at, self.borrowing.as_deref(), |repository| {
read.take()?(&super::commit_only(repository, id)?)
})
}
pub(super) fn commit(&self, id: git2::Oid) -> Option<Rc<Parsed>> {
let remembered = COMMITS.with(|commits| {
let commits = commits.borrow();
self.shared
.iter()
.find_map(|store| commits.get(&(Source::Store(store.clone()), id)))
.or_else(|| commits.get(&(self.own(), id)))
.cloned()
});
if remembered.is_some() {
return remembered;
}
super::spend()?;
let parse = |commit: &git2::Commit<'_>| Parsed {
parents: commit.parent_ids().collect(),
tree: commit.tree_id(),
time: commit.committer().when().seconds(),
};
for store in &self.shared {
let read = crate::native_refs::with_store(store, |repository| {
repository
.odb()
.ok()?
.exists_ext(id, git2::OdbLookupFlags::NO_REFRESH)
.then(|| super::commit_only(repository, id).map(|commit| parse(&commit)))
});
if let Some(read) = read {
let parsed = Rc::new(read?);
COMMITS.with(|commits| {
commits
.borrow_mut()
.insert((Source::Store(store.clone()), id), parsed.clone())
});
return Some(parsed);
}
}
let parsed = Rc::new(crate::native_refs::with_objects(
&self.at,
self.borrowing.as_deref(),
|repository| Some(parse(&super::commit_only(repository, id)?)),
)?);
COMMITS.with(|commits| {
commits
.borrow_mut()
.insert((self.own(), id), parsed.clone())
});
Some(parsed)
}
fn paint(
&self,
one: git2::Oid,
two: git2::Oid,
) -> Option<(HashMap<git2::Oid, u8>, Vec<git2::Oid>)> {
let mut flags: HashMap<git2::Oid, u8> = HashMap::new();
let mut queue: BinaryHeap<(i64, std::cmp::Reverse<u64>, git2::Oid)> = BinaryHeap::new();
let mut pushed = 0_u64;
let mut push = |queue: &mut BinaryHeap<_>, id: git2::Oid, time: i64| {
queue.push((time, std::cmp::Reverse(pushed), id));
pushed += 1;
};
flags.insert(one, PARENT1);
push(&mut queue, one, self.commit(one)?.time);
*flags.entry(two).or_insert(0) |= PARENT2;
push(&mut queue, two, self.commit(two)?.time);
let mut results = Vec::new();
while queue
.iter()
.any(|(_, _, id)| flags.get(id).is_some_and(|flag| flag & STALE == 0))
{
let Some((_, _, id)) = queue.pop() else {
break;
};
let held = flags.get(&id).copied().unwrap_or(0);
let mut marks = held & (PARENT1 | PARENT2 | STALE);
if marks == PARENT1 | PARENT2 {
if held & RESULT == 0 {
flags.insert(id, held | RESULT);
results.push(id);
}
marks |= STALE;
}
for parent in self.commit(id)?.parents.clone() {
let carried = flags.get(&parent).copied().unwrap_or(0);
if carried & marks == marks {
continue;
}
let time = self.commit(parent)?.time;
flags.insert(parent, carried | marks);
push(&mut queue, parent, time);
}
}
Some((flags, results))
}
pub(super) fn merge_base(&self, a: git2::Oid, b: git2::Oid) -> Option<git2::Oid> {
if a == b {
self.commit(a)?;
return Some(a);
}
let (flags, results) = self.paint(a, b)?;
match results
.into_iter()
.filter(|id| flags.get(id).is_some_and(|flag| flag & STALE == 0))
.collect::<Vec<_>>()
.as_slice()
{
[only] => Some(*only),
_ => None,
}
}
pub(super) fn is_ancestor(
&self,
ancestor: git2::Oid,
descendant: git2::Oid,
) -> Option<bool> {
if ancestor == descendant {
self.commit(ancestor)?;
return Some(true);
}
let (flags, _) = self.paint(ancestor, descendant)?;
Some(flags.get(&ancestor).is_some_and(|flag| flag & PARENT2 != 0))
}
fn ancestors(&self, id: git2::Oid) -> Option<Ancestors> {
let key = (self.own(), id);
if let Some(known) = ANCESTORS.with(|ancestors| ancestors.borrow().get(&key).cloned()) {
return known;
}
let mut seen = HashSet::from([id]);
let mut pending = vec![id];
let listed = loop {
let Some(next) = pending.pop() else {
break Some(Rc::new(seen));
};
let Some(parsed) = self.commit(next) else {
break None;
};
for parent in parsed.parents.iter().copied() {
if seen.insert(parent) {
pending.push(parent);
}
}
if seen.len() > ANCESTRY {
break None;
}
};
ANCESTORS.with(|ancestors| ancestors.borrow_mut().insert(key, listed.clone()));
listed
}
fn only(&self, to: git2::Oid, from: &[git2::Oid]) -> Option<HashSet<git2::Oid>> {
let excluded = from
.iter()
.map(|id| self.ancestors(*id))
.collect::<Option<Vec<_>>>()?;
let mut members = HashSet::new();
let mut pending = vec![to];
while let Some(next) = pending.pop() {
if excluded.iter().any(|set| set.contains(&next)) || !members.insert(next) {
continue;
}
pending.extend(self.commit(next)?.parents.iter().copied());
}
Some(members)
}
pub(super) fn count(&self, a: git2::Oid, hidden: &[git2::Oid]) -> Option<usize> {
self.commit(a)?;
Some(self.only(a, hidden)?.len())
}
pub(super) fn linear_range(
&self,
from: git2::Oid,
to: git2::Oid,
) -> Option<Vec<git2::Oid>> {
self.commit(from)?;
let members = self.only(to, &[from])?;
let mut chain = Vec::with_capacity(members.len());
let mut next = Some(to);
while let Some(id) = next.filter(|id| members.contains(id)) {
let parsed = self.commit(id)?;
if parsed.parents.len() > 1 || chain.len() >= members.len() {
return None;
}
chain.push(id);
next = parsed.parents.first().copied();
}
(chain.len() == members.len()).then_some(chain)
}
}
}
fn peel(revision: &str) -> Option<Peel> {
if let Some(id) = revision.strip_suffix("^1^{commit}") {
return Some(Peel::FirstParent(oid(id)?));
}
if let Some(id) = revision.strip_suffix("^{commit}") {
return Some(Peel::Commit(oid(id)?));
}
Some(Peel::Tree(oid(revision.strip_suffix("^{tree}")?)?))
}
fn commit(repository: &git2::Repository, id: git2::Oid) -> Option<git2::Commit<'_>> {
match kind(repository, id)? {
git2::ObjectType::Commit => repository.find_commit(id).ok(),
git2::ObjectType::Tag => commit(repository, repository.find_tag(id).ok()?.target_id()),
_ => None,
}
}
fn body<'c>(commit: &'c git2::Commit<'_>) -> Option<&'c [u8]> {
if !matches!(commit.message_encoding(), Ok(None)) {
return None;
}
let raw = commit.message_raw_bytes();
(!raw.is_empty() && !raw.contains(&0)).then_some(raw)
}
fn linear_range(
repository: &git2::Repository,
from: git2::Oid,
to: git2::Oid,
) -> Option<Vec<git2::Commit<'_>>> {
let (from, to) = (commit(repository, from)?.id(), commit(repository, to)?.id());
let mut walk = repository.revwalk().ok()?;
walk.push(to).ok()?;
walk.hide(from).ok()?;
let mut members = BTreeSet::new();
for listed in walk {
members.insert(listed.ok()?);
}
let mut chain = Vec::with_capacity(members.len());
let mut next = Some(to);
while let Some(id) = next.filter(|id| members.contains(id)) {
let current = repository.find_commit(id).ok()?;
if current.parent_count() > 1 {
return None;
}
next = current.parent_id(0).ok();
chain.push(current);
}
(chain.len() == members.len()).then_some(chain)
}
fn changed(
repository: &git2::Repository,
a: git2::Oid,
b: git2::Oid,
paths: &[&[u8]],
) -> Option<BTreeSet<Vec<u8>>> {
let (a, b) = (tree_of(repository, a)?, tree_of(repository, b)?);
let mut names = BTreeSet::new();
differ(repository, Some(a), Some(b), &mut Vec::new(), &mut names)?;
names.retain(|path: &Vec<u8>| {
paths.is_empty()
|| paths.iter().any(|spec| {
path.as_slice() == *spec
|| (path.starts_with(spec) && path.get(spec.len()) == Some(&b'/'))
})
});
Some(names)
}
const TREE: i32 = 0o040_000;
type Entries = BTreeMap<Vec<u8>, (git2::Oid, i32)>;
fn differ(
repository: &git2::Repository,
old: Option<git2::Oid>,
new: Option<git2::Oid>,
prefix: &mut Vec<u8>,
names: &mut BTreeSet<Vec<u8>>,
) -> Option<()> {
if old == new {
return Some(());
}
let entries = |id: Option<git2::Oid>| -> Option<Entries> {
let Some(id) = id else {
return Some(BTreeMap::new());
};
spend()?;
let tree = repository.find_tree(id).ok()?;
Some(
tree.iter()
.map(|entry| (entry.name_bytes().to_vec(), (entry.id(), entry.filemode())))
.collect(),
)
};
let (before, after) = (entries(old)?, entries(new)?);
let mut named: BTreeSet<&Vec<u8>> = before.keys().collect();
named.extend(after.keys());
let tree = |entry: Option<&(git2::Oid, i32)>| {
entry.and_then(|(id, mode)| (*mode == TREE).then_some(*id))
};
let file = |entry: Option<&(git2::Oid, i32)>| entry.is_some_and(|(_, mode)| *mode != TREE);
for name in named {
let (was, is) = (before.get(name), after.get(name));
if was == is {
continue;
}
let length = prefix.len();
prefix.extend_from_slice(name);
if file(was) || file(is) {
names.insert(prefix.clone());
}
if tree(was).is_some() || tree(is).is_some() {
prefix.push(b'/');
differ(repository, tree(was), tree(is), prefix, names)?;
}
prefix.truncate(length);
}
Some(())
}
fn kind(repository: &git2::Repository, id: git2::Oid) -> Option<git2::ObjectType> {
Some(repository.odb().ok()?.read_header(id).ok()?.1)
}
fn commit_only(repository: &git2::Repository, id: git2::Oid) -> Option<git2::Commit<'_>> {
(kind(repository, id)? == git2::ObjectType::Commit)
.then(|| repository.find_commit(id).ok())
.flatten()
}
fn tree_of(repository: &git2::Repository, id: git2::Oid) -> Option<git2::Oid> {
match kind(repository, id)? {
git2::ObjectType::Tree => Some(id),
git2::ObjectType::Commit => Some(repository.find_commit(id).ok()?.tree_id()),
git2::ObjectType::Tag => tree_of(repository, repository.find_tag(id).ok()?.target_id()),
_ => None,
}
}
fn exited(status: i32, stdout: String) -> Output {
Output {
status,
ended: Ended::Code(status),
stdout,
stderr: String::new(),
read_failures: Vec::new(),
}
}
fn printed(stdout: String) -> Option<Output> {
Some(exited(0, stdout))
}
fn printed_text(stdout: Vec<u8>) -> Option<Output> {
String::from_utf8(stdout)
.ok()
.map(|stdout| exited(0, stdout))
}