use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AddPlan {
pub destination: PathBuf,
pub insert_at: usize,
pub quiet: bool,
}
const FLAG_OPTIONS: &[&[u8]] = &[b"--detach", b"-d", b"--quiet", b"-q", b"--lock", b"--checkout"];
const VALUE_OPTIONS: &[&[u8]] = &[b"-b", b"--reason"];
const HARMLESS_GLOBALS: &[&[u8]] = &[b"--no-pager", b"-P", b"--paginate", b"-p", b"--no-advice", b"--no-lazy-fetch"];
pub fn start_directory(global_prefix: &[OsString], current: &Path) -> Option<PathBuf> {
let mut start: PathBuf = current.to_path_buf();
let mut tokens = global_prefix.iter();
while let Some(token) = tokens.next() {
let bytes: &[u8] = token.as_encoded_bytes();
if bytes == b"-C" {
let target: &OsString = tokens.next()?;
if Path::new(target).components().any(|part| { return part == std::path::Component::ParentDir; }) {
return None;
}
if !target.is_empty() {
start = start.join(target);
}
} else if !HARMLESS_GLOBALS.contains(&bytes) {
return None;
}
}
return Some(start);
}
fn recorded_destination(start: &Path, operand: &OsStr) -> Option<PathBuf> {
use std::os::unix::ffi::OsStrExt;
let mut trimmed: &[u8] = operand.as_bytes();
while let Some(rest) = trimmed.strip_suffix(b"/") {
trimmed = rest;
}
let last: &[u8] = trimmed.rsplit(|byte| { return *byte == b'/'; }).next()?;
if last.is_empty() || last == b"." || last == b".." {
return None;
}
let joined: PathBuf = start.join(OsStr::from_bytes(trimmed));
let parent: PathBuf = std::fs::canonicalize(joined.parent()?).ok()?;
let recorded: PathBuf = parent.join(OsStr::from_bytes(last));
if std::fs::symlink_metadata(recorded.as_path()).is_ok_and(|found: std::fs::Metadata| { return found.file_type().is_symlink(); }) {
return None;
}
return Some(recorded);
}
fn add_operands(region: &[OsString]) -> Option<(Vec<&OsString>, bool)> {
let mut operands: Vec<&OsString> = Vec::new();
let mut quiet: bool = false;
let mut tokens = region.iter();
let mut options_ended: bool = false;
while let Some(token) = tokens.next() {
let bytes: &[u8] = token.as_encoded_bytes();
if options_ended || !bytes.starts_with(b"-") || bytes == b"-" {
operands.push(token);
} else if bytes == b"--" {
options_ended = true;
} else if VALUE_OPTIONS.contains(&bytes) {
tokens.next()?;
} else if bytes == b"-q" || bytes == b"--quiet" {
quiet = true;
} else if !bytes.starts_with(b"--reason=") && !FLAG_OPTIONS.contains(&bytes) {
return None;
}
}
return if (1..=2).contains(&operands.len()) { Some((operands, quiet)) } else { None };
}
pub fn parse_add_plan(arguments: &[OsString], subcommand_index: usize, start: &Path) -> Option<AddPlan> {
if arguments.get(subcommand_index)?.as_encoded_bytes() != b"worktree" {
return None;
}
let word_index: usize = subcommand_index + 1;
if arguments.get(word_index)?.as_encoded_bytes() != b"add" {
return None;
}
let (operands, quiet): (Vec<&OsString>, bool) = add_operands(&arguments[word_index + 1..])?;
let destination: PathBuf = recorded_destination(start, operands[0])?;
return Some(AddPlan { destination, insert_at: word_index + 1, quiet });
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Takes {
Nothing,
Required,
}
struct AddOption {
short: Option<u8>,
long: Option<&'static str>,
takes: Takes,
}
const ADD_OPTIONS: &[AddOption] = &[
AddOption { short: Some(b'f'), long: Some("force"), takes: Takes::Nothing },
AddOption { short: Some(b'b'), long: None, takes: Takes::Required },
AddOption { short: Some(b'B'), long: None, takes: Takes::Required },
AddOption { short: None, long: Some("orphan"), takes: Takes::Nothing },
AddOption { short: Some(b'd'), long: Some("detach"), takes: Takes::Nothing },
AddOption { short: None, long: Some("checkout"), takes: Takes::Nothing },
AddOption { short: None, long: Some("lock"), takes: Takes::Nothing },
AddOption { short: None, long: Some("reason"), takes: Takes::Required },
AddOption { short: Some(b'q'), long: Some("quiet"), takes: Takes::Nothing },
AddOption { short: None, long: Some("track"), takes: Takes::Nothing },
AddOption { short: None, long: Some("guess-remote"), takes: Takes::Nothing },
AddOption { short: None, long: Some("relative-paths"), takes: Takes::Nothing },
];
const COPY_NEUTRAL_GLOBALS: &[&[u8]] = &[
b"--no-pager", b"-P", b"--paginate", b"-p", b"--no-replace-objects", b"--no-lazy-fetch", b"--no-optional-locks",
b"--no-advice", b"--literal-pathspecs", b"--no-literal-pathspecs", b"--glob-pathspecs", b"--noglob-pathspecs",
b"--icase-pathspecs", b"--bare",
];
const COPY_VALUED_GLOBALS: &[&[u8]] = &[b"--git-dir", b"--work-tree", b"--namespace", b"--config-env", b"--attr-source"];
const COPY_SEPARATE_VALUE_GLOBALS: &[&[u8]] = &[b"-c", b"--shallow-file"];
pub fn copy_start_directory(global_prefix: &[OsString], current: &Path) -> Option<PathBuf> {
let mut start: PathBuf = current.to_path_buf();
let mut tokens = global_prefix.iter();
while let Some(token) = tokens.next() {
let bytes: &[u8] = token.as_encoded_bytes();
if bytes == b"-C" {
let target: &OsString = tokens.next()?;
if !target.is_empty() {
start = start.join(target);
}
} else if COPY_SEPARATE_VALUE_GLOBALS.contains(&bytes) || COPY_VALUED_GLOBALS.contains(&bytes) {
tokens.next()?;
} else if COPY_VALUED_GLOBALS.iter().any(|name| { return bytes.strip_prefix(*name).is_some_and(|rest| { return rest.first() == Some(&b'='); }); })
|| bytes.starts_with(b"--exec-path=")
{
continue;
} else if !COPY_NEUTRAL_GLOBALS.contains(&bytes) {
return None;
}
}
return Some(start);
}
struct LongOption<'a> {
option: &'static AddOption,
unset: bool,
attached: Option<&'a str>,
}
fn resolve_long(arg: &str) -> Option<LongOption<'_>> {
return resolve_in(ADD_OPTIONS, arg);
}
fn resolve_in<'a>(table: &'static [AddOption], arg: &'a str) -> Option<LongOption<'a>> {
let arg_end: usize = arg.find('=').unwrap_or(arg.len());
let (arg_start, unset, double_negation): (&str, bool, bool) = match arg.strip_prefix("no-") {
Some(rest) => match rest.strip_prefix("no-") {
Some(inner) => (inner, false, true),
None => (rest, true, false),
},
None => (arg, false, false),
};
if double_negation {
return None;
}
let length: usize = arg_end.checked_sub(arg.len() - arg_start.len())?;
let mut abbreviated: Option<(&'static AddOption, bool)> = None;
let mut ambiguous: bool = false;
for option in table {
let Some(name) = option.long else { continue };
if let Some(rest) = arg_start.strip_prefix(name) {
if let Some(value) = rest.strip_prefix('=') {
return Some(LongOption { option, unset, attached: Some(value) });
}
if rest.is_empty() {
return Some(LongOption { option, unset, attached: None });
}
continue;
}
if name.as_bytes().get(..length) == Some(&arg_start.as_bytes()[..length]) {
ambiguous |= abbreviated.is_some();
abbreviated = Some((option, unset));
}
}
if ambiguous {
return None;
}
let (matched, matched_unset) = abbreviated?;
let attached: Option<&str> = if arg_end < arg.len() { Some(&arg[arg_end + 1..]) } else { None };
return Some(LongOption { option: matched, unset: matched_unset, attached });
}
fn take_value<'a>(option: &AddOption, unset: bool, attached: Option<&str>, tokens: &mut impl Iterator<Item = &'a OsString>) -> Option<()> {
if unset && attached.is_some() {
return None;
}
match option.takes {
Takes::Nothing => return if attached.is_some() { None } else { Some(()) },
Takes::Required => {
if !unset && attached.is_none() {
tokens.next()?;
}
return Some(());
}
}
}
fn copy_add_operands(region: &[OsString]) -> Option<Vec<&OsString>> {
let mut operands: Vec<&OsString> = Vec::new();
let mut tokens = region.iter();
while let Some(token) = tokens.next() {
let bytes: &[u8] = token.as_encoded_bytes();
if !bytes.starts_with(b"-") || bytes == b"-" {
operands.push(token);
} else if bytes == b"--" || bytes == b"--end-of-options" {
operands.extend(tokens.by_ref());
} else if let Some(long) = bytes.strip_prefix(b"--") {
let resolved: LongOption<'_> = resolve_long(std::str::from_utf8(long).ok()?)?;
take_value(resolved.option, resolved.unset, resolved.attached, &mut tokens)?;
} else {
let letters: &[u8] = &bytes[1..];
for (position, letter) in letters.iter().enumerate() {
let option: &AddOption = ADD_OPTIONS.iter().find(|candidate| { return candidate.short == Some(*letter); })?;
if option.takes == Takes::Required {
if position + 1 == letters.len() {
tokens.next()?;
}
break;
}
}
}
}
return if (1..=2).contains(&operands.len()) { Some(operands) } else { None };
}
fn creating_destination(start: &Path, operand: &OsStr) -> Option<PathBuf> {
use std::os::unix::ffi::OsStrExt;
let mut trimmed: &[u8] = operand.as_bytes();
while let Some(rest) = trimmed.strip_suffix(b"/") {
trimmed = rest;
}
let last: &[u8] = trimmed.rsplit(|byte| { return *byte == b'/'; }).next()?;
if last.is_empty() || last == b"." || last == b".." {
return None;
}
let joined: PathBuf = start.join(OsStr::from_bytes(trimmed));
if std::fs::symlink_metadata(joined.as_path()).is_ok_and(|found: std::fs::Metadata| { return found.file_type().is_symlink(); }) {
return None;
}
let existing: &Path = joined.ancestors().find(|ancestor| { return ancestor.exists(); })?;
let mut resolved: PathBuf = std::fs::canonicalize(existing).ok()?;
for part in joined.strip_prefix(existing).ok()?.components() {
match part {
std::path::Component::Normal(name) => resolved.push(name),
std::path::Component::CurDir => {}
_ => return None,
}
}
return Some(resolved);
}
pub fn parse_copy_destination(arguments: &[OsString], subcommand_index: usize, start: &Path) -> Option<PathBuf> {
if arguments.get(subcommand_index)?.as_encoded_bytes() != b"worktree" {
return None;
}
let word_index: usize = subcommand_index + 1;
if arguments.get(word_index)?.as_encoded_bytes() != b"add" {
return None;
}
let operands: Vec<&OsString> = copy_add_operands(&arguments[word_index + 1..])?;
return creating_destination(start, operands[0]);
}
#[cfg(test)]
#[path = "worktree_add_plan_tests.rs"]
mod tests;