use std::collections::HashSet;
use std::path::PathBuf;
use std::{fs, io};
use super::{Error, Scope, Selection};
use crate::pathspec::{Context, Pathspec};
use crate::refs::{Dereference, Expected, RefEdit, RefName, Reflog, Target};
use crate::{ObjectId, Repository, Signature};
const FILES: [&str; 9] = [
"BISECT_HEAD",
"BISECT_START",
"BISECT_TERMS",
"BISECT_NAMES",
"BISECT_LOG",
"BISECT_EXPECTED_REV",
"BISECT_ANCESTORS_OK",
"BISECT_FIRST_PARENT",
"BISECT_RUN",
];
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Terms {
new: String,
old: String,
}
impl Default for Terms {
fn default() -> Self {
Self {
new: "bad".into(),
old: "good".into(),
}
}
}
impl Terms {
pub fn new(new: impl Into<String>, old: impl Into<String>) -> Result<Self, Error> {
let terms = Self {
new: new.into(),
old: old.into(),
};
for term in [&terms.new, &terms.old] {
check_term(term)?;
}
if terms.new == terms.old {
return Err(Error::Term(terms.new));
}
Ok(terms)
}
pub fn new_term(&self) -> &str {
&self.new
}
pub fn old_term(&self) -> &str {
&self.old
}
}
fn check_term(term: &str) -> Result<(), Error> {
const RESERVED: [&str; 11] = [
"help",
"start",
"skip",
"next",
"reset",
"visualize",
"view",
"replay",
"log",
"run",
"terms",
];
let valid = !term.is_empty()
&& !RESERVED.contains(&term)
&& RefName::new(format!("refs/bisect/{term}")).is_ok();
if valid {
Ok(())
} else {
Err(Error::Term(term.to_owned()))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Mark {
New,
Old,
Skip,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Step {
Waiting {
new: bool,
olds: usize,
},
MergeBase(ObjectId),
Test {
commit: ObjectId,
remaining: usize,
steps: usize,
},
Found(ObjectId),
OnlySkipped(Vec<ObjectId>),
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct Start {
pub new: Option<ObjectId>,
pub olds: Vec<ObjectId>,
pub terms: Terms,
pub paths: Vec<Vec<u8>>,
pub first_parent: bool,
pub no_checkout: bool,
}
impl Start {
#[must_use]
pub fn with_new(mut self, new: ObjectId) -> Self {
self.new = Some(new);
self
}
#[must_use]
pub fn with_olds(mut self, olds: Vec<ObjectId>) -> Self {
self.olds = olds;
self
}
#[must_use]
pub fn with_terms(mut self, terms: Terms) -> Self {
self.terms = terms;
self
}
#[must_use]
pub fn with_paths(mut self, paths: Vec<Vec<u8>>) -> Self {
self.paths = paths;
self
}
#[must_use]
pub fn with_first_parent(mut self, first_parent: bool) -> Self {
self.first_parent = first_parent;
self
}
#[must_use]
pub fn with_no_checkout(mut self, no_checkout: bool) -> Self {
self.no_checkout = no_checkout;
self
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub enum ResetTo {
#[default]
Original,
Commit(ObjectId),
Current,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Verdict {
New,
Old,
Skip,
Abort,
}
impl Verdict {
pub fn from_exit_code(code: Option<i32>) -> Self {
match code {
Some(0) => Self::Old,
Some(125) => Self::Skip,
Some(1..=127) => Self::New,
_ => Self::Abort,
}
}
}
#[derive(Debug)]
pub struct Bisect<'repo> {
repo: &'repo Repository,
committer: Signature,
terms: Terms,
start: Vec<u8>,
paths: Vec<Vec<u8>>,
first_parent: bool,
no_checkout: bool,
new: Option<ObjectId>,
olds: Vec<ObjectId>,
skipped: Vec<ObjectId>,
}
impl<'repo> Bisect<'repo> {
pub fn open(repo: &'repo Repository, committer: Signature) -> Result<Option<Self>, Error> {
let git_dir = repo.git_dir();
let start = match fs::read(git_dir.join("BISECT_START")) {
Ok(start) => start,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let terms = match fs::read_to_string(git_dir.join("BISECT_TERMS")) {
Ok(text) => {
let mut lines = text.lines();
match (lines.next(), lines.next()) {
(Some(new), Some(old)) => Terms::new(new, old)?,
_ => return Err(Error::State("BISECT_TERMS")),
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => Terms::default(),
Err(error) => return Err(error.into()),
};
let paths = match fs::read(git_dir.join("BISECT_NAMES")) {
Ok(bytes) => parse_names(&bytes).ok_or(Error::State("BISECT_NAMES"))?,
Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error.into()),
};
let mut bisect = Self {
repo,
committer,
terms,
start: trim_newline(&start).to_vec(),
paths,
first_parent: git_dir.join("BISECT_FIRST_PARENT").exists(),
no_checkout: false,
new: None,
olds: Vec::new(),
skipped: Vec::new(),
};
bisect.read_refs()?;
Ok(Some(bisect))
}
pub fn start(
repo: &'repo Repository,
committer: Signature,
options: Start,
) -> Result<(Self, Step), Error> {
if repo.git_dir().join("BISECT_START").exists() {
return Err(Error::InProgress);
}
let references = repo.references()?;
let start = match references.read(&RefName::HEAD)? {
Some(Target::Symbolic(name)) => match name.as_bytes().strip_prefix(b"refs/heads/") {
Some(branch) => branch.to_vec(),
None => name.as_bytes().to_vec(),
},
Some(Target::Direct(id)) => id.to_string().into_bytes(),
None => return Err(Error::NoHead),
};
if repo.head()?.id.is_none() {
return Err(Error::NoHead);
}
if !options.no_checkout && repo.worktree().is_none() {
return Err(Error::Bare);
}
if options.no_checkout && repo.reference_backend() == crate::refs::Backend::Reftable {
return Err(Error::Unsupported("--no-checkout in reftable repositories"));
}
let mut bisect = Self {
repo,
committer,
terms: options.terms.clone(),
start: start.clone(),
paths: options.paths.clone(),
first_parent: options.first_parent,
no_checkout: false,
new: None,
olds: Vec::new(),
skipped: Vec::new(),
};
bisect.scope()?;
let result = bisect.write_start(&options);
if let Err(error) = result {
let _ = bisect.remove_state();
return Err(error);
}
let step = bisect.next_step()?;
Ok((bisect, step))
}
fn write_start(&mut self, options: &Start) -> Result<(), Error> {
let git_dir = self.repo.git_dir();
let mut log = Vec::new();
if let Some(new) = options.new {
log.extend(self.log_comment(self.terms.new_term(), new)?);
}
for &old in &options.olds {
log.extend(self.log_comment(self.terms.old_term(), old)?);
}
let mut words: Vec<Vec<u8>> = Vec::new();
if self.terms != Terms::default() {
words.push(format!("--term-new={}", self.terms.new_term()).into_bytes());
words.push(format!("--term-old={}", self.terms.old_term()).into_bytes());
}
if options.no_checkout {
words.push(b"--no-checkout".to_vec());
}
if options.first_parent {
words.push(b"--first-parent".to_vec());
}
words.extend(options.new.iter().map(|id| id.to_string().into_bytes()));
words.extend(options.olds.iter().map(|id| id.to_string().into_bytes()));
let names = names_line(&self.paths);
if !self.paths.is_empty() {
words.push(b"--".to_vec());
words.extend(self.paths.iter().cloned());
}
log.extend_from_slice(b"git bisect start");
for word in &words {
log.push(b' ');
log.extend(quote(word));
}
log.push(b'\n');
fs::write(
git_dir.join("BISECT_START"),
[&self.start[..], b"\n"].concat(),
)?;
fs::write(
git_dir.join("BISECT_TERMS"),
format!("{}\n{}\n", self.terms.new_term(), self.terms.old_term()),
)?;
fs::write(git_dir.join("BISECT_NAMES"), names)?;
if options.first_parent {
fs::write(git_dir.join("BISECT_FIRST_PARENT"), b"")?;
}
if options.no_checkout {
let head = self.repo.head()?.id.ok_or(Error::NoHead)?;
self.set_bisect_head(head)?;
}
if let Some(new) = options.new {
self.write_mark(Mark::New, new)?;
}
for &old in &options.olds {
self.write_mark(Mark::Old, old)?;
}
fs::write(git_dir.join("BISECT_LOG"), log)?;
Ok(())
}
pub fn terms(&self) -> &Terms {
&self.terms
}
pub fn new_commit(&self) -> Option<ObjectId> {
self.new
}
pub fn old_commits(&self) -> &[ObjectId] {
&self.olds
}
pub fn skipped(&self) -> &[ObjectId] {
&self.skipped
}
pub fn is_no_checkout(&self) -> bool {
self.no_checkout
}
pub fn paths(&self) -> &[Vec<u8>] {
&self.paths
}
pub fn scope(&self) -> Result<Scope, Error> {
let paths = if self.paths.is_empty() {
None
} else {
Some(Pathspec::parse(&self.paths, &Context::new())?)
};
Ok(Scope::default()
.with_first_parent(self.first_parent)
.with_paths(paths))
}
pub fn current(&self) -> Result<ObjectId, Error> {
if self.no_checkout {
return read_bisect_head(self.repo)?.ok_or(Error::State("BISECT_HEAD"));
}
self.repo
.references()?
.resolve(&RefName::HEAD)?
.id
.ok_or(Error::NoHead)
}
pub fn candidates(&self) -> Result<super::Candidates, Error> {
let new = self.new.ok_or(Error::Waiting)?;
if self.olds.is_empty() {
return Err(Error::Waiting);
}
self.repo
.objects()?
.bisect_candidates(new, &self.olds, &self.scope()?)
}
pub fn mark(&mut self, mark: Mark, commits: &[ObjectId]) -> Result<Step, Error> {
self.record(mark, commits)?;
self.next_step()
}
fn record(&mut self, mark: Mark, commits: &[ObjectId]) -> Result<(), Error> {
if mark != Mark::Skip {
let marker = self.repo.git_dir().join("BISECT_ANCESTORS_OK");
match fs::remove_file(marker) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
let mut log = Vec::new();
for &commit in commits {
let term = self.term(mark);
log.extend(self.log_comment(term, commit)?);
log.extend(format!("git bisect {term} {commit}\n").into_bytes());
self.write_mark(mark, commit)?;
}
self.append_log(&log)
}
fn term(&self, mark: Mark) -> &str {
match mark {
Mark::New => self.terms.new_term(),
Mark::Old => self.terms.old_term(),
Mark::Skip => "skip",
}
}
pub fn next_step(&mut self) -> Result<Step, Error> {
let Some(new) = self.new else {
return Ok(Step::Waiting {
new: false,
olds: self.olds.len(),
});
};
if self.olds.is_empty() {
return Ok(Step::Waiting { new: true, olds: 0 });
}
if let Some(base) = self.untested_merge_base(new)? {
self.check_out(base)?;
return Ok(Step::MergeBase(base));
}
let candidates = self.candidates()?;
let skipped: HashSet<ObjectId> = self.skipped.iter().copied().collect();
match candidates.select(&skipped) {
Selection::Test {
commit,
remaining,
steps,
} => {
self.check_out(commit)?;
Ok(Step::Test {
commit,
remaining,
steps,
})
}
Selection::Found(commit) => {
let comment =
self.log_comment(&format!("first '{}' commit", self.terms.new_term()), commit)?;
self.append_log(&comment)?;
Ok(Step::Found(commit))
}
Selection::OnlySkipped(possible) => {
let mut log = b"# only skipped commits left to test\n".to_vec();
let label = format!("possible first '{}' commit", self.terms.new_term());
for &commit in possible.iter().rev() {
log.extend(self.log_comment(&label, commit)?);
}
self.append_log(&log)?;
Ok(Step::OnlySkipped(possible))
}
Selection::Untestable => Err(Error::Untestable),
}
}
fn untested_merge_base(&mut self, new: ObjectId) -> Result<Option<ObjectId>, Error> {
let marker = self.repo.git_dir().join("BISECT_ANCESTORS_OK");
if marker.exists() {
return Ok(None);
}
let objects = self.repo.objects()?;
let mut unrelated = Vec::new();
for &old in &self.olds {
if !objects.is_ancestor(old, new)? {
unrelated.push(old);
}
}
for &old in &unrelated {
for base in objects.merge_bases(new, old)? {
if base == new {
return Err(Error::NotAncestors { base });
}
if self.olds.contains(&base) || self.skipped.contains(&base) {
continue;
}
return Ok(Some(base));
}
}
fs::write(marker, b"")?;
Ok(None)
}
pub fn reset(mut self, to: ResetTo) -> Result<(), Error> {
if !self.no_checkout {
match to {
ResetTo::Current => {}
ResetTo::Commit(commit) => self.check_out(commit)?,
ResetTo::Original => self.check_out_original()?,
}
}
self.remove_state()
}
pub fn log(&self) -> Result<Vec<u8>, Error> {
Ok(fs::read(self.repo.git_dir().join("BISECT_LOG"))?)
}
pub fn replay(
repo: &'repo Repository,
committer: Signature,
log: &[u8],
) -> Result<(Self, Step), Error> {
if let Some(existing) = Self::open(repo, committer.clone())? {
existing.reset(ResetTo::Original)?;
}
let mut bisect: Option<Self> = None;
for (number, line) in log.split(|&b| b == b'\n').enumerate() {
let line = trim_newline(line);
if line.is_empty() || line.starts_with(b"#") {
continue;
}
let words = unquote_words(line).ok_or(Error::Replay(number + 1))?;
let words: Vec<&[u8]> = words.iter().map(Vec::as_slice).collect();
let [b"git", b"bisect", command, arguments @ ..] = words.as_slice() else {
return Err(Error::Replay(number + 1));
};
if *command == b"start" {
let options = parse_start(repo, arguments).ok_or(Error::Replay(number + 1))?;
let start = Self::start_quiet(repo, committer.clone(), options)?;
bisect = Some(start);
continue;
}
let session = bisect.as_mut().ok_or(Error::Replay(number + 1))?;
let mark = if *command == session.terms.new_term().as_bytes() {
Mark::New
} else if *command == session.terms.old_term().as_bytes() {
Mark::Old
} else if *command == b"skip" {
Mark::Skip
} else {
return Err(Error::Replay(number + 1));
};
let commits = arguments
.iter()
.map(|word| {
std::str::from_utf8(word)
.ok()
.and_then(|text| text.parse().ok())
.ok_or(Error::Replay(number + 1))
})
.collect::<Result<Vec<ObjectId>, Error>>()?;
session.record(mark, &commits)?;
}
let mut bisect = bisect.ok_or(Error::Replay(0))?;
let step = bisect.next_step()?;
Ok((bisect, step))
}
fn start_quiet(
repo: &'repo Repository,
committer: Signature,
options: Start,
) -> Result<Self, Error> {
let mut bisect = Self {
repo,
committer,
terms: options.terms.clone(),
start: Vec::new(),
paths: options.paths.clone(),
first_parent: options.first_parent,
no_checkout: false,
new: None,
olds: Vec::new(),
skipped: Vec::new(),
};
bisect.start = match repo.references()?.read(&RefName::HEAD)? {
Some(Target::Symbolic(name)) => name
.as_bytes()
.strip_prefix(b"refs/heads/")
.unwrap_or(name.as_bytes())
.to_vec(),
Some(Target::Direct(id)) => id.to_string().into_bytes(),
None => return Err(Error::NoHead),
};
bisect.scope()?;
bisect.write_start(&options)?;
Ok(bisect)
}
pub fn run(&mut self, mut test: impl FnMut(ObjectId) -> Verdict) -> Result<Step, Error> {
let expected = fs::read_to_string(self.repo.git_dir().join("BISECT_EXPECTED_REV"))
.ok()
.and_then(|text| text.trim().parse::<ObjectId>().ok());
let mut commit = match expected {
Some(expected) if self.current()? == expected => expected,
_ => match self.next_step()? {
Step::Test { commit, .. } | Step::MergeBase(commit) => commit,
Step::Waiting { .. } => return Err(Error::Waiting),
step => return Ok(step),
},
};
loop {
let mark = match test(commit) {
Verdict::New => Mark::New,
Verdict::Old => Mark::Old,
Verdict::Skip => Mark::Skip,
Verdict::Abort => return Err(Error::Aborted(commit)),
};
commit = match self.mark(mark, &[commit])? {
Step::Test { commit, .. } | Step::MergeBase(commit) => commit,
Step::Waiting { .. } => return Err(Error::Waiting),
step => return Ok(step),
};
}
}
fn read_refs(&mut self) -> Result<(), Error> {
let references = self.repo.references()?;
let new_name = format!("refs/bisect/{}", self.terms.new_term());
let old_prefix = format!("refs/bisect/{}-", self.terms.old_term());
for reference in references.list_prefix(b"refs/bisect/")? {
let name = reference.name.as_bytes();
let Target::Direct(id) = reference.target else {
continue;
};
if name == new_name.as_bytes() {
self.new = Some(id);
} else if name.starts_with(old_prefix.as_bytes()) {
self.olds.push(id);
} else if name.starts_with(b"refs/bisect/skip-") {
self.skipped.push(id);
}
}
self.no_checkout = read_bisect_head(self.repo)?.is_some();
Ok(())
}
fn write_mark(&mut self, mark: Mark, commit: ObjectId) -> Result<(), Error> {
let name = match mark {
Mark::New => format!("refs/bisect/{}", self.terms.new_term()),
Mark::Old => format!("refs/bisect/{}-{commit}", self.terms.old_term()),
Mark::Skip => format!("refs/bisect/skip-{commit}"),
};
let name = RefName::new(name).map_err(|_| Error::Term(self.term(mark).to_owned()))?;
let edit = RefEdit::new(
name,
Some(Target::Direct(commit)),
Expected::Any,
Reflog::Preserve,
);
self.repo.references()?.transaction(&[edit])?;
match mark {
Mark::New => self.new = Some(commit),
Mark::Old if !self.olds.contains(&commit) => self.olds.push(commit),
Mark::Skip if !self.skipped.contains(&commit) => self.skipped.push(commit),
Mark::Old | Mark::Skip => {}
}
Ok(())
}
fn set_bisect_head(&mut self, commit: ObjectId) -> Result<(), Error> {
use std::io::Write;
let git_dir = self.repo.git_dir();
let lock = git_dir.join("BISECT_HEAD.lock");
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock)?;
let written = writeln!(file, "{commit}").and_then(|()| file.sync_all());
drop(file);
if let Err(error) = written.and_then(|()| fs::rename(&lock, git_dir.join("BISECT_HEAD"))) {
let _ = fs::remove_file(&lock);
return Err(error.into());
}
self.no_checkout = true;
Ok(())
}
fn check_out(&mut self, commit: ObjectId) -> Result<(), Error> {
fs::write(
self.repo.git_dir().join("BISECT_EXPECTED_REV"),
format!("{commit}\n"),
)?;
if self.no_checkout {
return self.set_bisect_head(commit);
}
let label = commit.to_string();
self.switch(Target::Direct(commit), commit, label.as_bytes())
}
fn check_out_original(&self) -> Result<(), Error> {
let start = &self.start;
if let Ok(id) = std::str::from_utf8(start)
.unwrap_or_default()
.parse::<ObjectId>()
{
return self.switch(Target::Direct(id), id, start);
}
let branch = RefName::branch(start).map_err(|_| Error::State("BISECT_START"))?;
let id = self
.repo
.references()?
.resolve(&branch)?
.id
.ok_or(Error::State("BISECT_START"))?;
self.switch(Target::Symbolic(branch), id, start)
}
fn switch(&self, target: Target, commit: ObjectId, label: &[u8]) -> Result<(), Error> {
let references = self.repo.references()?;
let stored = references.read(&RefName::HEAD)?;
let from = match &stored {
Some(Target::Symbolic(name)) => name
.as_bytes()
.strip_prefix(b"refs/heads/")
.unwrap_or(name.as_bytes())
.to_vec(),
Some(Target::Direct(id)) => id.to_string().into_bytes(),
None => Vec::new(),
};
let objects = self.repo.objects()?;
let baseline = match references.resolve(&RefName::HEAD)?.id {
Some(id) => Some(objects.read_commit(id)?.tree()),
None => None,
};
if stored.as_ref() == Some(&target) {
return Ok(());
}
let tree = objects.read_commit(commit)?.tree();
let message = [b"checkout: moving from ".as_slice(), &from, b" to ", label].concat();
let expected = stored.map_or(Expected::Absent, Expected::Value);
let edit = RefEdit::new(
RefName::HEAD,
Some(target),
expected,
Reflog::Append {
committer: self.committer.clone(),
message,
},
)
.with_dereference(Dereference::Stored);
let prepared = references.prepare_transaction(&[edit])?;
if baseline != Some(tree) {
self.repo
.checkout_tree_configured(baseline, Some(tree))
.map_err(|failure| Error::Checkout(Box::new(failure)))?;
}
prepared.publish()?;
Ok(())
}
fn remove_state(&self) -> Result<(), Error> {
let references = self.repo.references()?;
let mut edits = Vec::new();
for reference in references.list_prefix(b"refs/bisect/")? {
if let Target::Direct(id) = reference.target {
edits.push(RefEdit::delete(reference.name, id, Reflog::Delete));
}
}
if !edits.is_empty() {
references.transaction(&edits)?;
}
for file in FILES {
match fs::remove_file(self.repo.git_dir().join(file)) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
fn append_log(&self, bytes: &[u8]) -> Result<(), Error> {
use std::io::Write;
let path: PathBuf = self.repo.git_dir().join("BISECT_LOG");
let mut file = fs::OpenOptions::new()
.append(true)
.create(true)
.open(path)?;
file.write_all(bytes)?;
Ok(())
}
fn log_comment(&self, label: &str, commit: ObjectId) -> Result<Vec<u8>, Error> {
let message = self.repo.objects()?.read_commit(commit)?;
let subject = subject(message.message());
Ok([
format!("# {label}: [{commit}] ").as_bytes(),
&subject,
b"\n",
]
.concat())
}
}
fn read_bisect_head(repo: &Repository) -> Result<Option<ObjectId>, Error> {
match fs::read_to_string(repo.git_dir().join("BISECT_HEAD")) {
Ok(text) => text
.trim()
.parse()
.map(Some)
.map_err(|_| Error::State("BISECT_HEAD")),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
fn subject(message: &[u8]) -> Vec<u8> {
let mut subject = Vec::new();
for line in message.split(|&b| b == b'\n') {
let line = trim_newline(line);
if line.iter().all(u8::is_ascii_whitespace) {
if subject.is_empty() {
continue;
}
break;
}
if !subject.is_empty() {
subject.push(b' ');
}
subject.extend_from_slice(line.trim_ascii());
}
subject
}
fn trim_newline(bytes: &[u8]) -> &[u8] {
bytes.strip_suffix(b"\n").unwrap_or(bytes)
}
pub(super) fn quote(word: &[u8]) -> Vec<u8> {
let mut quoted = vec![b'\''];
for &byte in word {
if byte == b'\'' {
quoted.extend_from_slice(b"'\\''");
} else {
quoted.push(byte);
}
}
quoted.push(b'\'');
quoted
}
fn names_line(paths: &[Vec<u8>]) -> Vec<u8> {
let mut line = Vec::new();
if !paths.is_empty() {
line.extend_from_slice(b" '--'");
for path in paths {
line.push(b' ');
line.extend(quote(path));
}
}
line.push(b'\n');
line
}
fn parse_names(bytes: &[u8]) -> Option<Vec<Vec<u8>>> {
let mut words = unquote_words(trim_newline(bytes))?;
if words.first().is_some_and(|word| word == b"--") {
words.remove(0);
}
Some(words)
}
fn unquote_words(line: &[u8]) -> Option<Vec<Vec<u8>>> {
let mut words = Vec::new();
let mut rest = line;
loop {
rest = rest.trim_ascii_start();
if rest.is_empty() {
return Some(words);
}
let mut word = Vec::new();
while let Some((&byte, tail)) = rest.split_first() {
match byte {
b' ' | b'\t' => break,
b'\'' => {
let end = tail.iter().position(|&b| b == b'\'')?;
word.extend_from_slice(&tail[..end]);
rest = &tail[end + 1..];
continue;
}
b'\\' => {
let (&escaped, after) = tail.split_first()?;
word.push(escaped);
rest = after;
continue;
}
_ => word.push(byte),
}
rest = tail;
}
words.push(word);
}
}
fn parse_start(repo: &Repository, arguments: &[&[u8]]) -> Option<Start> {
let mut options = Start::default();
let mut new_term = None;
let mut old_term = None;
let mut revisions = Vec::new();
let mut iter = arguments.iter();
while let Some(&word) = iter.next() {
let text = std::str::from_utf8(word).ok()?;
if word == b"--" {
options.paths = iter.map(|w| w.to_vec()).collect();
break;
} else if let Some(term) = text
.strip_prefix("--term-new=")
.or(text.strip_prefix("--term-bad="))
{
new_term = Some(term.to_owned());
} else if let Some(term) = text
.strip_prefix("--term-old=")
.or(text.strip_prefix("--term-good="))
{
old_term = Some(term.to_owned());
} else if text == "--no-checkout" {
options.no_checkout = true;
} else if text == "--first-parent" {
options.first_parent = true;
} else {
revisions.push(text.to_owned());
}
}
if new_term.is_some() || old_term.is_some() {
options.terms = Terms::new(
new_term.unwrap_or_else(|| "bad".into()),
old_term.unwrap_or_else(|| "good".into()),
)
.ok()?;
}
let objects = repo.objects().ok()?;
let resolver = crate::revision::Resolver::new(repo, &objects).ok()?;
let mut ids = revisions.iter().map(|revision| {
let revision = revision.parse().ok()?;
resolver
.resolve_to(&revision, crate::ObjectKind::Commit)
.ok()
});
if let Some(first) = ids.next() {
options.new = Some(first?);
options.olds = ids.collect::<Option<Vec<_>>>()?;
}
Some(options)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_round_trip_git_bytes() {
let paths = vec![b"d".to_vec(), b"f 3".to_vec(), b"it's".to_vec()];
let line = names_line(&paths);
assert_eq!(line, b" '--' 'd' 'f 3' 'it'\\''s'\n");
assert_eq!(parse_names(&line), Some(paths));
assert_eq!(names_line(&[]), b"\n");
assert_eq!(parse_names(b"\n"), Some(vec![]));
}
#[test]
fn subject_joins_first_paragraph() {
assert_eq!(subject(b"c5\n\nbody 5\n"), b"c5");
assert_eq!(subject(b"one\ntwo\n\nbody"), b"one two");
}
#[test]
fn reserved_terms_are_refused() {
assert!(Terms::new("new", "old").is_ok());
assert!(Terms::new("run", "old").is_err());
assert!(Terms::new("a b", "old").is_err());
}
}