use anyhow::{Result, bail};
use git2::{ObjectType, Oid};
use crate::core::agent_mode::{self, HunkItem};
use crate::core::diff::{BINARY_ENTRY, DiffHunk};
use crate::tui::hunk_selector::{FileEntry, HunkOrigin};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct HunkArgs {
pub ids: Vec<String>,
pub from: Option<String>,
}
impl HunkArgs {
pub fn new(ids: Vec<String>, from: Option<String>) -> Self {
Self { ids, from }
}
pub fn is_empty(&self) -> bool {
self.ids.is_empty()
}
}
pub fn patch_command(command: &str, files: &[String]) -> String {
let mut out = format!("{command} -p");
for file in files {
out.push(' ');
out.push_str("ed(file));
}
out
}
pub fn git_args_suffix(git_args: &[&str]) -> String {
if git_args.is_empty() {
return String::new();
}
let mut out = " --".to_string();
for arg in git_args {
out.push(' ');
out.push_str("ed(arg));
}
out
}
pub fn worktree_picker(
hunks: HunkArgs,
command: String,
target_hash: Option<&str>,
git_args: &[&str],
) -> Picker {
Picker {
hunks,
command,
target_hash: target_hash.map(str::to_string),
git_args: git_args_suffix(git_args),
}
}
#[derive(Debug, Clone)]
pub struct Picker {
pub hunks: HunkArgs,
pub command: String,
pub target_hash: Option<String>,
pub git_args: String,
}
pub fn quoted(arg: &str) -> String {
shell_escape::unix::escape(arg.into()).into_owned()
}
fn hunk_id(path: &str, index: usize) -> String {
format!("{path}:{}", index + 1)
}
pub fn picked_ids(entries: &[FileEntry]) -> Vec<String> {
let mut ids = Vec::new();
for file in entries {
for (index, entry) in file.hunks.iter().enumerate() {
if entry.selected {
ids.push(hunk_id(&file.path, index));
}
}
}
ids
}
pub fn fingerprint(oid: &str, target: Option<&str>, entries: &[FileEntry]) -> String {
let mut payload = String::from(oid);
payload.push('\n');
payload.push_str(target.unwrap_or_default());
payload.push('\n');
for file in entries {
payload.push_str(&file.path);
payload.push('\0');
for entry in &file.hunks {
payload.push(match entry.origin {
HunkOrigin::Staged => 'S',
HunkOrigin::Unstaged => 'U',
HunkOrigin::Commit => 'C',
});
payload.push_str(&entry.hunk.text);
payload.push('\0');
}
}
let digest =
Oid::hash_object(ObjectType::Blob, payload.as_bytes()).expect("hashing a blob cannot fail");
digest.to_string()[..12].to_string()
}
fn new_file_summary(hunk: &DiffHunk) -> String {
let lines = hunk
.text
.lines()
.filter(|line| line.starts_with('+'))
.count();
format!("(new file, {lines} line(s))")
}
pub fn items(entries: Vec<FileEntry>) -> Vec<HunkItem> {
let mut items = Vec::new();
for file in entries {
let untracked = file.is_untracked();
for (index, entry) in file.hunks.into_iter().enumerate() {
let whole_new_file = untracked && entry.hunk.is_whole_new_file();
let diff = if whole_new_file {
new_file_summary(&entry.hunk)
} else {
entry.hunk.text
};
items.push(HunkItem {
id: hunk_id(&file.path, index),
path: file.path.clone(),
diff,
staged: entry.selected,
});
}
}
items
}
pub(crate) fn refuse_losing_index_content(files: &[FileEntry]) -> Result<()> {
for file in files {
for (index, entry) in file.hunks.iter().enumerate() {
if entry.origin == HunkOrigin::Staged
&& !entry.selected
&& only_in_index(file, &entry.hunk)
{
let id = hunk_id(&file.path, index);
bail!(
"Unstaging `{id}` would lose what only the index holds: \
the working tree changed `{}` there again\n\
Keep `{id}` staged",
file.path
);
}
}
}
Ok(())
}
fn only_in_index(file: &FileEntry, staged: &DiffHunk) -> bool {
let mut unstaged = file
.hunks
.iter()
.filter(|h| h.origin == HunkOrigin::Unstaged)
.map(|h| &h.hunk);
if staged.text == BINARY_ENTRY {
return unstaged.next().is_some();
}
let added = staged.added_lines();
if added.is_empty() {
return false;
}
unstaged.any(|u| {
!u.is_text()
|| u.modified_lines
.iter()
.any(|l| added.binary_search(l).is_ok())
})
}
pub fn respond(oid: &str, entries: Vec<FileEntry>, picker: &Picker) -> anyhow::Error {
let fingerprint = fingerprint(oid, picker.target_hash.as_deref(), &entries);
let hint = format!(
"re-run with: {} --hunks <id> [--hunks <id>...] --hunks-from {fingerprint}{}",
picker.command, picker.git_args
);
agent_mode::respond_needs_hunks(items(entries), fingerprint, &hint)
}
pub fn apply(oid: &str, entries: &mut [FileEntry], picker: &Picker) -> Result<()> {
let current = fingerprint(oid, picker.target_hash.as_deref(), entries);
let args = &picker.hunks;
match args.from.as_deref() {
Some(from) if from == current => {}
Some(from) => bail!(
"The hunks changed since the listing fingerprinted {from} (now {current})\n\
Re-run with -p alone to list them again"
),
None => bail!("--hunks requires --hunks-from <fingerprint>"),
}
let mut picked = Vec::with_capacity(args.ids.len());
for id in &args.ids {
let parsed = id.rsplit_once(':').and_then(|(path, n)| {
let digits = !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit());
digits
.then(|| n.parse::<usize>().ok())
.flatten()
.filter(|n| *n > 0)
.map(|n| (path, n - 1))
});
let Some((path, index)) = parsed else {
bail!("Invalid hunk id `{id}`\nIds look like `src/main.rs:1`");
};
let found = entries
.iter()
.position(|file| file.path == path)
.filter(|&file_index| index < entries[file_index].hunks.len());
let Some(file_index) = found else {
let separator = if id.contains(',') {
"\nPass one `--hunks` per id — they are not comma-separated"
} else {
""
};
bail!("No hunk `{id}` in this diff{separator}");
};
picked.push((file_index, index));
}
for file in entries.iter_mut() {
for entry in &mut file.hunks {
entry.selected = false;
}
}
for (file_index, index) in picked {
entries[file_index].hunks[index].selected = true;
}
Ok(())
}
#[cfg(test)]
#[path = "hunk_select_test.rs"]
mod tests;