1173 lines
41 KiB
Rust
1173 lines
41 KiB
Rust
use super::*;
|
|
use std::collections::BTreeMap;
|
|
#[cfg(unix)]
|
|
use std::os::unix::ffi::OsStringExt;
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(super) enum GitChangeKind {
|
|
Added,
|
|
Modified,
|
|
Deleted,
|
|
}
|
|
|
|
impl GitChangeKind {
|
|
pub(super) fn marker(self) -> &'static str {
|
|
match self {
|
|
Self::Added => "A",
|
|
Self::Modified => "M",
|
|
Self::Deleted => "D",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(super) struct GitFile {
|
|
pub(super) path: PathBuf,
|
|
pub(super) display_path: String,
|
|
pub(super) staged_kind: Option<GitChangeKind>,
|
|
pub(super) worktree_kind: Option<GitChangeKind>,
|
|
}
|
|
|
|
impl GitFile {
|
|
pub(super) fn kind(&self) -> GitChangeKind {
|
|
self.worktree_kind.or(self.staged_kind).unwrap()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(super) struct GitWorktree {
|
|
pub(super) root: PathBuf,
|
|
pub(super) files: Vec<GitFile>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(super) enum GitDiffLineKind {
|
|
Section,
|
|
Header,
|
|
Hunk,
|
|
Context,
|
|
Addition,
|
|
Deletion,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(super) struct GitDiffLine {
|
|
pub(super) kind: GitDiffLineKind,
|
|
pub(super) old_number: Option<u32>,
|
|
pub(super) new_number: Option<u32>,
|
|
pub(super) text: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(super) enum GitDiffRow {
|
|
Header(GitDiffLine),
|
|
Pair {
|
|
old: Option<GitDiffLine>,
|
|
new: Option<GitDiffLine>,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(super) struct GitDiff {
|
|
pub(super) path: String,
|
|
pub(super) lines: Vec<GitDiffLine>,
|
|
}
|
|
|
|
impl GitDiff {
|
|
pub(super) fn split_rows(&self) -> Vec<GitDiffRow> {
|
|
let mut rows = Vec::new();
|
|
let mut index = 0;
|
|
while index < self.lines.len() {
|
|
let line = &self.lines[index];
|
|
match line.kind {
|
|
GitDiffLineKind::Section | GitDiffLineKind::Header | GitDiffLineKind::Hunk => {
|
|
rows.push(GitDiffRow::Header(line.clone()));
|
|
index += 1;
|
|
}
|
|
GitDiffLineKind::Context => {
|
|
rows.push(GitDiffRow::Pair {
|
|
old: Some(line.clone()),
|
|
new: Some(line.clone()),
|
|
});
|
|
index += 1;
|
|
}
|
|
GitDiffLineKind::Deletion => {
|
|
let deletion_start = index;
|
|
while index < self.lines.len()
|
|
&& self.lines[index].kind == GitDiffLineKind::Deletion
|
|
{
|
|
index += 1;
|
|
}
|
|
let addition_start = index;
|
|
while index < self.lines.len()
|
|
&& self.lines[index].kind == GitDiffLineKind::Addition
|
|
{
|
|
index += 1;
|
|
}
|
|
let deletions = &self.lines[deletion_start..addition_start];
|
|
let additions = &self.lines[addition_start..index];
|
|
for offset in 0..deletions.len().max(additions.len()) {
|
|
rows.push(GitDiffRow::Pair {
|
|
old: deletions.get(offset).cloned(),
|
|
new: additions.get(offset).cloned(),
|
|
});
|
|
}
|
|
}
|
|
GitDiffLineKind::Addition => {
|
|
rows.push(GitDiffRow::Pair {
|
|
old: None,
|
|
new: Some(line.clone()),
|
|
});
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
rows
|
|
}
|
|
}
|
|
|
|
pub(super) struct ActiveGitOperation {
|
|
pub(super) project_id: i32,
|
|
pub(super) label: String,
|
|
receiver: mpsc::Receiver<Result<(), String>>,
|
|
clear_commit_on_success: bool,
|
|
}
|
|
|
|
impl App {
|
|
pub(super) fn refresh_git_worktree(&mut self, project_id: i32) {
|
|
let Some(path) = self
|
|
.projects
|
|
.iter()
|
|
.find(|item| item.project.id == project_id)
|
|
.map(|item| PathBuf::from(&item.project.path))
|
|
else {
|
|
self.git_worktrees.remove(&project_id);
|
|
return;
|
|
};
|
|
match read_worktree(&path) {
|
|
Ok(worktree) => {
|
|
self.git_worktrees.insert(project_id, worktree);
|
|
if self.git_selected_project == Some(project_id) {
|
|
let files = &self.git_worktrees[&project_id].files;
|
|
self.git_selected_files
|
|
.retain(|path| files.iter().any(|file| file.path == *path));
|
|
}
|
|
}
|
|
Err(error) => {
|
|
self.git_worktrees.remove(&project_id);
|
|
if self.git_states.contains_key(&project_id) {
|
|
self.error = Some(format!("Could not read Git status: {error}"));
|
|
}
|
|
}
|
|
}
|
|
if self.selected_project == Some(project_id) {
|
|
self.last_git_scan = Instant::now();
|
|
}
|
|
}
|
|
|
|
pub(super) fn open_git_diff(&mut self, path: &Path) {
|
|
let Some(project_id) = self.selected_project else {
|
|
return;
|
|
};
|
|
let Some(worktree) = self.git_worktrees.get(&project_id) else {
|
|
return;
|
|
};
|
|
let Some(file) = worktree.files.iter().find(|file| file.path == path) else {
|
|
self.error = Some("The selected Git file is no longer available.".into());
|
|
return;
|
|
};
|
|
match load_diff(&worktree.root, file, &self.config.git) {
|
|
Ok(diff) => {
|
|
self.git_diff = Some(diff);
|
|
self.git_diff_layout = self.config.git.diff_layout;
|
|
self.error = None;
|
|
}
|
|
Err(error) => self.error = Some(error),
|
|
}
|
|
}
|
|
|
|
pub(super) fn toggle_git_file(&mut self, path: PathBuf) {
|
|
let Some(project_id) = self.selected_project else {
|
|
return;
|
|
};
|
|
if !self
|
|
.git_worktrees
|
|
.get(&project_id)
|
|
.is_some_and(|worktree| worktree.files.iter().any(|file| file.path == path))
|
|
{
|
|
return;
|
|
}
|
|
if self.git_selected_project != Some(project_id) {
|
|
self.git_selected_files.clear();
|
|
self.git_selected_project = Some(project_id);
|
|
}
|
|
if !self.git_selected_files.remove(&path) {
|
|
self.git_selected_files.insert(path);
|
|
}
|
|
}
|
|
|
|
pub(super) fn stage_selected_git_files(&mut self) {
|
|
let Some((project_id, root, paths)) =
|
|
self.git_selection(|file| file.worktree_kind.is_some())
|
|
else {
|
|
self.error = Some("Select at least one file to stage.".into());
|
|
return;
|
|
};
|
|
self.start_git_operation(project_id, "Staging files", false, move || {
|
|
stage_paths(&root, &paths)
|
|
});
|
|
}
|
|
|
|
pub(super) fn unstage_selected_git_files(&mut self) {
|
|
let Some((project_id, root, paths)) = self.git_selection(|file| file.staged_kind.is_some())
|
|
else {
|
|
self.error = Some("Select at least one file to unstage.".into());
|
|
return;
|
|
};
|
|
self.start_git_operation(project_id, "Unstaging files", false, move || {
|
|
unstage_paths(&root, &paths)
|
|
});
|
|
}
|
|
|
|
pub(super) fn commit_git(&mut self) {
|
|
let message = self.git_commit_message.trim().to_owned();
|
|
if message.is_empty() {
|
|
self.error = Some("Enter a commit message.".into());
|
|
return;
|
|
}
|
|
let Some(project_id) = self.selected_project else {
|
|
return;
|
|
};
|
|
let Some(worktree) = self.git_worktrees.get(&project_id) else {
|
|
self.error = Some("The selected project is not a Git worktree.".into());
|
|
return;
|
|
};
|
|
if self.project_has_active_chat(project_id) {
|
|
self.error = Some("Stop active chats before committing worktree changes.".into());
|
|
return;
|
|
}
|
|
let root = worktree.root.clone();
|
|
let selected = self.selected_git_paths(project_id);
|
|
if !selected.is_empty() {
|
|
self.start_git_operation(project_id, "Committing selected files", true, move || {
|
|
stage_paths(&root, &selected)?;
|
|
commit_paths(&root, &message, &selected)
|
|
});
|
|
} else if worktree.files.iter().any(|file| file.staged_kind.is_some()) {
|
|
self.start_git_operation(project_id, "Committing staged changes", true, move || {
|
|
commit_index(&root, &message)
|
|
});
|
|
} else if worktree.files.is_empty() {
|
|
self.error = Some("There are no changes to commit.".into());
|
|
} else {
|
|
self.git_commit_all_confirmation = true;
|
|
}
|
|
}
|
|
|
|
pub(super) fn confirm_commit_all_git(&mut self) {
|
|
let message = self.git_commit_message.trim().to_owned();
|
|
let Some(project_id) = self.selected_project else {
|
|
return;
|
|
};
|
|
let Some(worktree) = self.git_worktrees.get(&project_id) else {
|
|
return;
|
|
};
|
|
let root = worktree.root.clone();
|
|
self.git_commit_all_confirmation = false;
|
|
self.start_git_operation(project_id, "Committing all changes", true, move || {
|
|
stage_all(&root)?;
|
|
commit_index(&root, &message)
|
|
});
|
|
}
|
|
|
|
pub(super) fn start_git_remote(&mut self, action: &'static str) {
|
|
let Some(project_id) = self.selected_project else {
|
|
return;
|
|
};
|
|
let Some(worktree) = self.git_worktrees.get(&project_id) else {
|
|
self.error = Some("The selected project is not a Git worktree.".into());
|
|
return;
|
|
};
|
|
if action == "pull" && self.project_has_active_chat(project_id) {
|
|
self.error = Some("Stop active chats before pulling worktree changes.".into());
|
|
return;
|
|
}
|
|
let root = worktree.root.clone();
|
|
let label = match action {
|
|
"fetch" => "Fetching from origin",
|
|
"pull" => "Pulling from origin",
|
|
"push" => "Pushing to origin",
|
|
_ => return,
|
|
};
|
|
let branch = self
|
|
.git_states
|
|
.get(&project_id)
|
|
.and_then(|state| state.current.clone());
|
|
if action != "fetch" && branch.is_none() {
|
|
self.error = Some("Attach HEAD to a local branch before pulling or pushing.".into());
|
|
return;
|
|
}
|
|
self.start_git_operation(project_id, label, false, move || {
|
|
run_git_remote(&root, action, branch.as_deref())
|
|
});
|
|
}
|
|
|
|
pub(super) fn poll_git_operation(&mut self) {
|
|
let result =
|
|
self.git_operation
|
|
.as_ref()
|
|
.and_then(|operation| match operation.receiver.try_recv() {
|
|
Ok(result) => Some(result),
|
|
Err(TryRecvError::Empty) => None,
|
|
Err(TryRecvError::Disconnected) => {
|
|
Some(Err("The Git operation stopped unexpectedly.".into()))
|
|
}
|
|
});
|
|
let Some(result) = result else {
|
|
return;
|
|
};
|
|
let operation = self.git_operation.take().unwrap();
|
|
match result {
|
|
Ok(()) => {
|
|
if operation.clear_commit_on_success {
|
|
self.git_commit_message.clear();
|
|
self.git_selected_files.clear();
|
|
}
|
|
self.error = None;
|
|
}
|
|
Err(error) => self.error = Some(error),
|
|
}
|
|
self.refresh_git_worktree(operation.project_id);
|
|
if self.selected_project == Some(operation.project_id) {
|
|
self.refresh_git_state();
|
|
}
|
|
}
|
|
|
|
fn git_selection(
|
|
&self,
|
|
include: impl Fn(&GitFile) -> bool,
|
|
) -> Option<(i32, PathBuf, Vec<PathBuf>)> {
|
|
let project_id = self.selected_project?;
|
|
let worktree = self.git_worktrees.get(&project_id)?;
|
|
let paths = self.selected_git_paths_matching(project_id, include);
|
|
(!paths.is_empty()).then(|| (project_id, worktree.root.clone(), paths))
|
|
}
|
|
|
|
fn selected_git_paths(&self, project_id: i32) -> Vec<PathBuf> {
|
|
self.selected_git_paths_matching(project_id, |_| true)
|
|
}
|
|
|
|
fn selected_git_paths_matching(
|
|
&self,
|
|
project_id: i32,
|
|
include: impl Fn(&GitFile) -> bool,
|
|
) -> Vec<PathBuf> {
|
|
if self.git_selected_project != Some(project_id) {
|
|
return Vec::new();
|
|
}
|
|
self.git_worktrees
|
|
.get(&project_id)
|
|
.map(|worktree| {
|
|
worktree
|
|
.files
|
|
.iter()
|
|
.filter(|file| self.git_selected_files.contains(&file.path) && include(file))
|
|
.map(|file| file.path.clone())
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn start_git_operation(
|
|
&mut self,
|
|
project_id: i32,
|
|
label: &str,
|
|
clear_commit_on_success: bool,
|
|
operation: impl FnOnce() -> Result<(), String> + Send + 'static,
|
|
) {
|
|
if self.git_operation.is_some() {
|
|
self.error = Some("Another Git operation is still running.".into());
|
|
return;
|
|
}
|
|
if self.project_has_active_chat(project_id)
|
|
&& !matches!(label, "Fetching from origin" | "Pushing to origin")
|
|
{
|
|
self.error = Some("Stop active chats before changing Git state.".into());
|
|
return;
|
|
}
|
|
let (sender, receiver) = mpsc::channel();
|
|
if let Err(error) = thread::Builder::new()
|
|
.name("ds4-git".into())
|
|
.spawn(move || {
|
|
let _ = sender.send(operation());
|
|
})
|
|
{
|
|
self.error = Some(format!("Could not start the Git operation: {error}"));
|
|
return;
|
|
}
|
|
self.git_operation = Some(ActiveGitOperation {
|
|
project_id,
|
|
label: label.into(),
|
|
receiver,
|
|
clear_commit_on_success,
|
|
});
|
|
self.error = None;
|
|
}
|
|
}
|
|
|
|
fn read_worktree(project_path: &Path) -> Result<GitWorktree, String> {
|
|
let repository = git2::Repository::discover(project_path).map_err(|error| error.to_string())?;
|
|
let root = repository
|
|
.workdir()
|
|
.ok_or_else(|| "bare repositories do not have a worktree".to_owned())?
|
|
.to_path_buf();
|
|
let mut options = git2::StatusOptions::new();
|
|
options.include_untracked(true).recurse_untracked_dirs(true);
|
|
let mut files = BTreeMap::<PathBuf, GitFile>::new();
|
|
let statuses = repository
|
|
.statuses(Some(&mut options))
|
|
.map_err(|error| error.to_string())?;
|
|
for entry in statuses.iter() {
|
|
#[cfg(unix)]
|
|
let path = PathBuf::from(std::ffi::OsString::from_vec(entry.path_bytes().to_vec()));
|
|
#[cfg(not(unix))]
|
|
let path = PathBuf::from(String::from_utf8_lossy(entry.path_bytes()).into_owned());
|
|
let display_path = path.to_string_lossy().into_owned();
|
|
let file = files.entry(path.clone()).or_insert_with(|| GitFile {
|
|
path,
|
|
display_path,
|
|
staged_kind: None,
|
|
worktree_kind: None,
|
|
});
|
|
let status = entry.status();
|
|
if status.intersects(git2::Status::INDEX_NEW) {
|
|
file.staged_kind = Some(GitChangeKind::Added);
|
|
} else if status.intersects(git2::Status::INDEX_DELETED) {
|
|
file.staged_kind = Some(GitChangeKind::Deleted);
|
|
} else if status.intersects(
|
|
git2::Status::INDEX_MODIFIED
|
|
| git2::Status::INDEX_RENAMED
|
|
| git2::Status::INDEX_TYPECHANGE,
|
|
) {
|
|
file.staged_kind = Some(GitChangeKind::Modified);
|
|
}
|
|
if status.intersects(git2::Status::WT_NEW) {
|
|
file.worktree_kind = Some(GitChangeKind::Added);
|
|
} else if status.intersects(git2::Status::WT_DELETED) {
|
|
file.worktree_kind = Some(GitChangeKind::Deleted);
|
|
} else if status.intersects(
|
|
git2::Status::WT_MODIFIED | git2::Status::WT_RENAMED | git2::Status::WT_TYPECHANGE,
|
|
) {
|
|
file.worktree_kind = Some(GitChangeKind::Modified);
|
|
}
|
|
if status.intersects(git2::Status::CONFLICTED) {
|
|
file.staged_kind = Some(GitChangeKind::Modified);
|
|
file.worktree_kind = Some(GitChangeKind::Modified);
|
|
}
|
|
}
|
|
files.retain(|_, file| file.staged_kind.is_some() || file.worktree_kind.is_some());
|
|
Ok(GitWorktree {
|
|
root,
|
|
files: files.into_values().collect(),
|
|
})
|
|
}
|
|
|
|
fn load_diff(root: &Path, file: &GitFile, settings: &GitConfig) -> Result<GitDiff, String> {
|
|
let repository = git2::Repository::discover(root).map_err(|error| error.to_string())?;
|
|
let index = repository.index().map_err(|error| error.to_string())?;
|
|
let mut lines = Vec::new();
|
|
if file.staged_kind.is_some() {
|
|
let head_tree = repository
|
|
.head()
|
|
.ok()
|
|
.and_then(|head| head.peel_to_tree().ok());
|
|
let mut options = diff_options(file, settings, false);
|
|
let diff = repository
|
|
.diff_tree_to_index(head_tree.as_ref(), Some(&index), Some(&mut options))
|
|
.map_err(|error| error.to_string())?;
|
|
lines.push(diff_section("Staged changes"));
|
|
lines.extend(render_diff(&diff)?);
|
|
}
|
|
if file.worktree_kind.is_some() {
|
|
let mut options = diff_options(file, settings, true);
|
|
let diff = repository
|
|
.diff_index_to_workdir(Some(&index), Some(&mut options))
|
|
.map_err(|error| error.to_string())?;
|
|
lines.push(diff_section("Worktree changes"));
|
|
lines.extend(render_diff(&diff)?);
|
|
}
|
|
if lines.is_empty() {
|
|
lines.push(diff_message("No textual diff is available."));
|
|
}
|
|
Ok(GitDiff {
|
|
path: file.display_path.clone(),
|
|
lines,
|
|
})
|
|
}
|
|
|
|
fn diff_options(file: &GitFile, settings: &GitConfig, worktree: bool) -> git2::DiffOptions {
|
|
let mut options = git2::DiffOptions::new();
|
|
options
|
|
.pathspec(&file.path)
|
|
.context_lines(settings.context_lines)
|
|
.interhunk_lines(settings.interhunk_lines)
|
|
.indent_heuristic(settings.indent_heuristic)
|
|
.ignore_blank_lines(settings.ignore_blank_lines);
|
|
match settings.diff_algorithm {
|
|
GitDiffAlgorithm::Default => {}
|
|
GitDiffAlgorithm::Patience => {
|
|
options.patience(true);
|
|
}
|
|
GitDiffAlgorithm::Minimal => {
|
|
options.minimal(true);
|
|
}
|
|
}
|
|
match settings.whitespace {
|
|
GitDiffWhitespace::ShowAll => {}
|
|
GitDiffWhitespace::IgnoreAll => {
|
|
options.ignore_whitespace(true);
|
|
}
|
|
GitDiffWhitespace::IgnoreChanges => {
|
|
options.ignore_whitespace_change(true);
|
|
}
|
|
GitDiffWhitespace::IgnoreEndOfLine => {
|
|
options.ignore_whitespace_eol(true);
|
|
}
|
|
}
|
|
if worktree {
|
|
options
|
|
.include_untracked(true)
|
|
.recurse_untracked_dirs(true)
|
|
.show_untracked_content(true);
|
|
}
|
|
options
|
|
}
|
|
|
|
fn render_diff(diff: &git2::Diff<'_>) -> Result<Vec<GitDiffLine>, String> {
|
|
let mut output = Vec::new();
|
|
diff.print(git2::DiffFormat::Patch, |_, _, line| {
|
|
if matches!(
|
|
line.origin_value(),
|
|
git2::DiffLineType::Context
|
|
| git2::DiffLineType::Addition
|
|
| git2::DiffLineType::Deletion
|
|
) {
|
|
output.push(line.origin() as u8);
|
|
}
|
|
output.extend_from_slice(line.content());
|
|
true
|
|
})
|
|
.map_err(|error| error.to_string())?;
|
|
let lines = parse_unified_diff(&String::from_utf8_lossy(&output));
|
|
Ok(if lines.is_empty() {
|
|
vec![diff_message("No textual diff is available.")]
|
|
} else {
|
|
lines
|
|
})
|
|
}
|
|
|
|
fn diff_section(text: &str) -> GitDiffLine {
|
|
GitDiffLine {
|
|
kind: GitDiffLineKind::Section,
|
|
old_number: None,
|
|
new_number: None,
|
|
text: text.into(),
|
|
}
|
|
}
|
|
|
|
fn diff_message(text: &str) -> GitDiffLine {
|
|
GitDiffLine {
|
|
kind: GitDiffLineKind::Header,
|
|
old_number: None,
|
|
new_number: None,
|
|
text: text.into(),
|
|
}
|
|
}
|
|
|
|
fn parse_unified_diff(diff: &str) -> Vec<GitDiffLine> {
|
|
let mut old_number = None;
|
|
let mut new_number = None;
|
|
diff.lines()
|
|
.map(|line| {
|
|
let (kind, old, new) = if line.starts_with("@@") {
|
|
let mut parts = line.split_whitespace();
|
|
let _ = parts.next();
|
|
old_number = parts.next().and_then(diff_range_start);
|
|
new_number = parts.next().and_then(diff_range_start);
|
|
(GitDiffLineKind::Hunk, None, None)
|
|
} else if line.starts_with("---") || line.starts_with("+++") {
|
|
(GitDiffLineKind::Header, None, None)
|
|
} else if line.starts_with('-') {
|
|
let current = old_number;
|
|
old_number = old_number.map(|number| number.saturating_add(1));
|
|
(GitDiffLineKind::Deletion, current, None)
|
|
} else if line.starts_with('+') {
|
|
let current = new_number;
|
|
new_number = new_number.map(|number| number.saturating_add(1));
|
|
(GitDiffLineKind::Addition, None, current)
|
|
} else if line.starts_with(' ') {
|
|
let current_old = old_number;
|
|
let current_new = new_number;
|
|
old_number = old_number.map(|number| number.saturating_add(1));
|
|
new_number = new_number.map(|number| number.saturating_add(1));
|
|
(GitDiffLineKind::Context, current_old, current_new)
|
|
} else {
|
|
(GitDiffLineKind::Header, None, None)
|
|
};
|
|
GitDiffLine {
|
|
kind,
|
|
old_number: old,
|
|
new_number: new,
|
|
text: line.to_owned(),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn diff_range_start(range: &str) -> Option<u32> {
|
|
range
|
|
.get(1..)?
|
|
.split_once(',')
|
|
.map_or(range.get(1..), |(start, _)| Some(start))?
|
|
.parse()
|
|
.ok()
|
|
}
|
|
|
|
fn stage_paths(root: &Path, paths: &[PathBuf]) -> Result<(), String> {
|
|
let repository = git2::Repository::discover(root).map_err(git_error("stage files"))?;
|
|
let mut index = repository
|
|
.index()
|
|
.map_err(git_error("open the Git index"))?;
|
|
for path in paths {
|
|
match root.join(path).symlink_metadata() {
|
|
Ok(_) => index.add_path(path).map_err(git_error("stage files"))?,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
index.remove_path(path).map_err(git_error("stage files"))?
|
|
}
|
|
Err(error) => return Err(format!("Could not stage files: {error}")),
|
|
}
|
|
}
|
|
index.write().map_err(git_error("write the Git index"))
|
|
}
|
|
|
|
fn stage_all(root: &Path) -> Result<(), String> {
|
|
let repository = git2::Repository::discover(root).map_err(git_error("stage files"))?;
|
|
let mut index = repository
|
|
.index()
|
|
.map_err(git_error("open the Git index"))?;
|
|
index
|
|
.add_all(["*"], git2::IndexAddOption::DEFAULT, None)
|
|
.map_err(git_error("stage files"))?;
|
|
index.write().map_err(git_error("write the Git index"))
|
|
}
|
|
|
|
fn unstage_paths(root: &Path, paths: &[PathBuf]) -> Result<(), String> {
|
|
let repository = git2::Repository::discover(root).map_err(git_error("unstage files"))?;
|
|
let head = repository
|
|
.head()
|
|
.ok()
|
|
.and_then(|head| head.peel_to_commit().ok());
|
|
repository
|
|
.reset_default(head.as_ref().map(|commit| commit.as_object()), paths.iter())
|
|
.map_err(git_error("unstage files"))
|
|
}
|
|
|
|
fn commit_paths(root: &Path, message: &str, paths: &[PathBuf]) -> Result<(), String> {
|
|
let repository =
|
|
git2::Repository::discover(root).map_err(git_error("commit selected files"))?;
|
|
let index = repository
|
|
.index()
|
|
.map_err(git_error("open the Git index"))?;
|
|
let mut commit_index = git2::Index::new().map_err(git_error("create a Git index"))?;
|
|
if let Ok(head) = repository.head().and_then(|head| head.peel_to_commit()) {
|
|
commit_index
|
|
.read_tree(&head.tree().map_err(git_error("read the HEAD tree"))?)
|
|
.map_err(git_error("read the HEAD tree"))?;
|
|
}
|
|
for path in paths {
|
|
if let Some(entry) = index.get_path(path, 0) {
|
|
commit_index
|
|
.add(&entry)
|
|
.map_err(git_error("prepare the commit"))?;
|
|
} else {
|
|
commit_index
|
|
.remove_path(path)
|
|
.map_err(git_error("prepare the commit"))?;
|
|
}
|
|
}
|
|
let tree = commit_index
|
|
.write_tree_to(&repository)
|
|
.map_err(git_error("write the commit tree"))?;
|
|
let commit = create_commit(&repository, message, tree)?;
|
|
let commit = repository
|
|
.find_object(commit, None)
|
|
.map_err(git_error("read the new commit"))?;
|
|
repository
|
|
.reset_default(Some(&commit), paths.iter())
|
|
.map_err(git_error("refresh the Git index"))
|
|
}
|
|
|
|
fn commit_index(root: &Path, message: &str) -> Result<(), String> {
|
|
let repository = git2::Repository::discover(root).map_err(git_error("commit changes"))?;
|
|
let tree = repository
|
|
.index()
|
|
.and_then(|mut index| index.write_tree())
|
|
.map_err(git_error("write the commit tree"))?;
|
|
create_commit(&repository, message, tree).map(|_| ())
|
|
}
|
|
|
|
fn create_commit(
|
|
repository: &git2::Repository,
|
|
message: &str,
|
|
tree: git2::Oid,
|
|
) -> Result<git2::Oid, String> {
|
|
let signature = repository
|
|
.signature()
|
|
.map_err(git_error("read Git identity"))?;
|
|
let tree = repository
|
|
.find_tree(tree)
|
|
.map_err(git_error("read the commit tree"))?;
|
|
let parent = repository
|
|
.head()
|
|
.ok()
|
|
.and_then(|head| head.peel_to_commit().ok());
|
|
let parents = parent.iter().collect::<Vec<_>>();
|
|
repository
|
|
.commit(
|
|
Some("HEAD"),
|
|
&signature,
|
|
&signature,
|
|
message,
|
|
&tree,
|
|
&parents,
|
|
)
|
|
.map_err(git_error("commit changes"))
|
|
}
|
|
|
|
fn run_git_remote(root: &Path, action: &str, branch: Option<&str>) -> Result<(), String> {
|
|
let repository =
|
|
git2::Repository::discover(root).map_err(|error| format!("Could not {action}: {error}"))?;
|
|
let mut remote = repository
|
|
.find_remote("origin")
|
|
.map_err(|error| format!("Could not {action}: {error}"))?;
|
|
match action {
|
|
"fetch" => {
|
|
let mut options = git2::FetchOptions::new();
|
|
options.remote_callbacks(remote_callbacks(&repository)?);
|
|
remote
|
|
.fetch(&[] as &[&str], Some(&mut options), None)
|
|
.map_err(git_error("fetch"))
|
|
}
|
|
"pull" => {
|
|
let branch = branch.ok_or_else(|| "The current branch is unavailable.".to_owned())?;
|
|
let mut options = git2::FetchOptions::new();
|
|
options.remote_callbacks(remote_callbacks(&repository)?);
|
|
remote
|
|
.fetch(&[branch], Some(&mut options), None)
|
|
.map_err(git_error("pull"))?;
|
|
merge_fetch_head(&repository, branch)
|
|
}
|
|
"push" => {
|
|
let branch = branch.ok_or_else(|| "The current branch is unavailable.".to_owned())?;
|
|
let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
|
|
let mut options = git2::PushOptions::new();
|
|
options.remote_callbacks(remote_callbacks(&repository)?);
|
|
remote
|
|
.push(&[&refspec], Some(&mut options))
|
|
.map_err(git_error("push"))
|
|
}
|
|
_ => Err("The Git remote action is unavailable.".into()),
|
|
}
|
|
}
|
|
|
|
fn merge_fetch_head(repository: &git2::Repository, branch: &str) -> Result<(), String> {
|
|
let fetch_head = repository
|
|
.find_reference("FETCH_HEAD")
|
|
.and_then(|reference| repository.reference_to_annotated_commit(&reference))
|
|
.map_err(git_error("read the fetched branch"))?;
|
|
let (analysis, _) = repository
|
|
.merge_analysis(&[&fetch_head])
|
|
.map_err(git_error("analyze the pull"))?;
|
|
if analysis.is_up_to_date() {
|
|
return Ok(());
|
|
}
|
|
if analysis.is_fast_forward() || analysis.is_unborn() {
|
|
let object = repository
|
|
.find_object(fetch_head.id(), None)
|
|
.map_err(git_error("read the fetched commit"))?;
|
|
repository
|
|
.checkout_tree(&object, Some(git2::build::CheckoutBuilder::new().safe()))
|
|
.map_err(git_error("check out the fetched commit"))?;
|
|
let reference = format!("refs/heads/{branch}");
|
|
repository
|
|
.reference(&reference, fetch_head.id(), true, "pull: fast-forward")
|
|
.and_then(|_| repository.set_head(&reference))
|
|
.map_err(git_error("fast-forward the current branch"))?;
|
|
return Ok(());
|
|
}
|
|
if !analysis.is_normal() {
|
|
return Err("Could not pull: the fetched branch cannot be merged.".into());
|
|
}
|
|
|
|
let local = repository
|
|
.head()
|
|
.and_then(|head| head.peel_to_commit())
|
|
.map_err(git_error("read the current commit"))?;
|
|
repository
|
|
.merge(
|
|
&[&fetch_head],
|
|
None,
|
|
Some(git2::build::CheckoutBuilder::new().safe()),
|
|
)
|
|
.map_err(git_error("merge the fetched branch"))?;
|
|
let mut index = repository
|
|
.index()
|
|
.map_err(git_error("read the merge index"))?;
|
|
if index.has_conflicts() {
|
|
return Err("Could not pull: the merge has conflicts.".into());
|
|
}
|
|
let tree = index
|
|
.write_tree()
|
|
.and_then(|id| repository.find_tree(id))
|
|
.map_err(git_error("write the merge tree"))?;
|
|
let remote = repository
|
|
.find_commit(fetch_head.id())
|
|
.map_err(git_error("read the fetched commit"))?;
|
|
let signature = repository
|
|
.signature()
|
|
.map_err(git_error("read Git identity"))?;
|
|
repository
|
|
.commit(
|
|
Some("HEAD"),
|
|
&signature,
|
|
&signature,
|
|
&format!("Merge remote-tracking branch 'origin/{branch}'"),
|
|
&tree,
|
|
&[&local, &remote],
|
|
)
|
|
.map_err(git_error("commit the merge"))?;
|
|
repository
|
|
.cleanup_state()
|
|
.map_err(git_error("finish the pull"))
|
|
}
|
|
|
|
fn remote_callbacks(
|
|
repository: &git2::Repository,
|
|
) -> Result<git2::RemoteCallbacks<'static>, String> {
|
|
let config = repository
|
|
.config()
|
|
.map_err(git_error("read Git configuration"))?;
|
|
let mut callbacks = git2::RemoteCallbacks::new();
|
|
callbacks.credentials(move |url, username, allowed| {
|
|
if allowed.contains(git2::CredentialType::SSH_KEY) {
|
|
return git2::Cred::ssh_key_from_agent(username.unwrap_or("git"));
|
|
}
|
|
if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
|
|
return git2::Cred::credential_helper(&config, url, username);
|
|
}
|
|
if allowed.contains(git2::CredentialType::USERNAME) {
|
|
return git2::Cred::username(username.unwrap_or("git"));
|
|
}
|
|
if allowed.contains(git2::CredentialType::DEFAULT) {
|
|
return git2::Cred::default();
|
|
}
|
|
Err(git2::Error::from_str(
|
|
"no supported Git credentials are available",
|
|
))
|
|
});
|
|
Ok(callbacks)
|
|
}
|
|
|
|
fn git_error(action: &'static str) -> impl FnOnce(git2::Error) -> String {
|
|
move |error| format!("Could not {action}: {error}")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
|
|
|
|
#[test]
|
|
fn libgit2_status_merges_staged_and_worktree_changes() {
|
|
let directory = repository_fixture();
|
|
std::fs::write(directory.join("added.txt"), "added\n").unwrap();
|
|
git2::Repository::open(&directory)
|
|
.unwrap()
|
|
.config()
|
|
.unwrap()
|
|
.set_bool("status.showUntrackedFiles", false)
|
|
.unwrap();
|
|
std::fs::write(directory.join("changed.txt"), "changed\n").unwrap();
|
|
stage_paths(&directory, &["changed.txt".into()]).unwrap();
|
|
std::fs::write(directory.join("changed.txt"), "changed again\n").unwrap();
|
|
std::fs::remove_file(directory.join("deleted.txt")).unwrap();
|
|
|
|
let worktree = read_worktree(&directory).unwrap();
|
|
let added = worktree
|
|
.files
|
|
.iter()
|
|
.find(|file| file.display_path == "added.txt")
|
|
.unwrap();
|
|
assert_eq!(added.worktree_kind, Some(GitChangeKind::Added));
|
|
let changed = worktree
|
|
.files
|
|
.iter()
|
|
.find(|file| file.display_path == "changed.txt")
|
|
.unwrap();
|
|
assert_eq!(changed.staged_kind, Some(GitChangeKind::Modified));
|
|
assert_eq!(changed.worktree_kind, Some(GitChangeKind::Modified));
|
|
let diff = load_diff(&worktree.root, changed, &GitConfig::default()).unwrap();
|
|
assert_eq!(
|
|
diff.lines
|
|
.iter()
|
|
.filter(|line| line.kind == GitDiffLineKind::Section)
|
|
.count(),
|
|
2
|
|
);
|
|
assert!(
|
|
diff.lines
|
|
.iter()
|
|
.any(|line| line.kind == GitDiffLineKind::Addition)
|
|
);
|
|
assert!(
|
|
diff.lines
|
|
.iter()
|
|
.any(|line| line.kind == GitDiffLineKind::Deletion)
|
|
);
|
|
let deleted = worktree
|
|
.files
|
|
.iter()
|
|
.find(|file| file.display_path == "deleted.txt")
|
|
.unwrap();
|
|
assert_eq!(deleted.worktree_kind, Some(GitChangeKind::Deleted));
|
|
|
|
let added_path = PathBuf::from("added.txt");
|
|
stage_paths(&directory, std::slice::from_ref(&added_path)).unwrap();
|
|
let worktree = read_worktree(&directory).unwrap();
|
|
let added = worktree
|
|
.files
|
|
.iter()
|
|
.find(|file| file.path == added_path)
|
|
.unwrap();
|
|
assert_eq!(added.staged_kind, Some(GitChangeKind::Added));
|
|
|
|
unstage_paths(&directory, std::slice::from_ref(&added_path)).unwrap();
|
|
let worktree = read_worktree(&directory).unwrap();
|
|
let added = worktree
|
|
.files
|
|
.iter()
|
|
.find(|file| file.path == added_path)
|
|
.unwrap();
|
|
assert_eq!(added.staged_kind, None);
|
|
assert_eq!(added.worktree_kind, Some(GitChangeKind::Added));
|
|
|
|
stage_paths(&directory, std::slice::from_ref(&added_path)).unwrap();
|
|
commit_paths(
|
|
&directory,
|
|
"Commit selected file",
|
|
std::slice::from_ref(&added_path),
|
|
)
|
|
.unwrap();
|
|
let worktree = read_worktree(&directory).unwrap();
|
|
assert!(!worktree.files.iter().any(|file| file.path == added_path));
|
|
assert_eq!(
|
|
worktree
|
|
.files
|
|
.iter()
|
|
.find(|file| file.display_path == "changed.txt")
|
|
.unwrap()
|
|
.staged_kind,
|
|
Some(GitChangeKind::Modified)
|
|
);
|
|
|
|
std::fs::remove_dir_all(directory).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn unstage_works_before_the_first_commit() {
|
|
let directory = empty_repository_fixture();
|
|
std::fs::write(directory.join("new.txt"), "new\n").unwrap();
|
|
let path = PathBuf::from("new.txt");
|
|
stage_paths(&directory, std::slice::from_ref(&path)).unwrap();
|
|
unstage_paths(&directory, std::slice::from_ref(&path)).unwrap();
|
|
|
|
let worktree = read_worktree(&directory).unwrap();
|
|
let file = worktree
|
|
.files
|
|
.iter()
|
|
.find(|file| file.path == path)
|
|
.unwrap();
|
|
assert_eq!(file.staged_kind, None);
|
|
assert_eq!(file.worktree_kind, Some(GitChangeKind::Added));
|
|
|
|
std::fs::remove_dir_all(directory).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn origin_fetch_pull_and_push_use_the_current_branch() {
|
|
let local = repository_fixture();
|
|
let remote = local.with_extension("remote.git");
|
|
let collaborator = local.with_extension("collaborator");
|
|
git2::Repository::init_bare(&remote).unwrap();
|
|
git2::Repository::open(&local)
|
|
.unwrap()
|
|
.remote("origin", remote.to_str().unwrap())
|
|
.unwrap();
|
|
run_git_remote(&local, "push", Some("main")).unwrap();
|
|
git2::Repository::open_bare(&remote)
|
|
.unwrap()
|
|
.set_head("refs/heads/main")
|
|
.unwrap();
|
|
|
|
let cloned = git2::build::RepoBuilder::new()
|
|
.clone(remote.to_str().unwrap(), &collaborator)
|
|
.unwrap();
|
|
configure_test_repository(&cloned);
|
|
drop(cloned);
|
|
std::fs::write(collaborator.join("remote.txt"), "from origin\n").unwrap();
|
|
stage_paths(&collaborator, &["remote.txt".into()]).unwrap();
|
|
commit_index(&collaborator, "Remote change").unwrap();
|
|
run_git_remote(&collaborator, "push", Some("main")).unwrap();
|
|
|
|
run_git_remote(&local, "fetch", None).unwrap();
|
|
run_git_remote(&local, "pull", Some("main")).unwrap();
|
|
assert_eq!(
|
|
std::fs::read_to_string(local.join("remote.txt")).unwrap(),
|
|
"from origin\n"
|
|
);
|
|
std::fs::write(local.join("local.txt"), "to origin\n").unwrap();
|
|
stage_paths(&local, &["local.txt".into()]).unwrap();
|
|
commit_index(&local, "Local change").unwrap();
|
|
run_git_remote(&local, "push", Some("main")).unwrap();
|
|
let content = {
|
|
let bare = git2::Repository::open_bare(&remote).unwrap();
|
|
let commit = bare
|
|
.find_branch("main", git2::BranchType::Local)
|
|
.unwrap()
|
|
.get()
|
|
.peel_to_commit()
|
|
.unwrap();
|
|
let tree = commit.tree().unwrap();
|
|
let entry = tree.get_path(Path::new("local.txt")).unwrap();
|
|
bare.find_blob(entry.id()).unwrap().content().to_vec()
|
|
};
|
|
assert_eq!(content, b"to origin\n");
|
|
|
|
std::fs::remove_dir_all(local).unwrap();
|
|
std::fs::remove_dir_all(remote).unwrap();
|
|
std::fs::remove_dir_all(collaborator).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn unified_diff_parser_aligns_replacements_for_split_view() {
|
|
let lines = parse_unified_diff(
|
|
"diff --git a/file b/file\n--- a/file\n+++ b/file\n@@ -2,2 +2,2 @@\n same\n-old\n+new\n",
|
|
);
|
|
assert_eq!(lines[4].old_number, Some(2));
|
|
assert_eq!(lines[4].new_number, Some(2));
|
|
assert_eq!(lines[5].old_number, Some(3));
|
|
assert_eq!(lines[6].new_number, Some(3));
|
|
let rows = GitDiff {
|
|
path: "file".into(),
|
|
lines,
|
|
}
|
|
.split_rows();
|
|
assert!(rows.iter().any(|row| matches!(
|
|
row,
|
|
GitDiffRow::Pair {
|
|
old: Some(old),
|
|
new: Some(new),
|
|
} if old.kind == GitDiffLineKind::Deletion
|
|
&& new.kind == GitDiffLineKind::Addition
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn configured_git_diff_options_change_the_rendered_patch() {
|
|
let directory = empty_repository_fixture();
|
|
std::fs::write(
|
|
directory.join("changed.txt"),
|
|
"first\nsecond\nthird\nfourth\nfifth\n",
|
|
)
|
|
.unwrap();
|
|
stage_paths(&directory, &["changed.txt".into()]).unwrap();
|
|
commit_index(&directory, "Initial").unwrap();
|
|
std::fs::write(
|
|
directory.join("changed.txt"),
|
|
"first\nsecond changed\nthird\nfourth changed\nfifth\n",
|
|
)
|
|
.unwrap();
|
|
let worktree = read_worktree(&directory).unwrap();
|
|
let changed = worktree
|
|
.files
|
|
.iter()
|
|
.find(|file| file.display_path == "changed.txt")
|
|
.unwrap();
|
|
let settings = GitConfig {
|
|
diff_layout: GitDiffLayout::Unified,
|
|
diff_algorithm: GitDiffAlgorithm::Patience,
|
|
context_lines: 0,
|
|
interhunk_lines: 0,
|
|
indent_heuristic: true,
|
|
whitespace: GitDiffWhitespace::ShowAll,
|
|
ignore_blank_lines: false,
|
|
};
|
|
|
|
let diff = load_diff(&worktree.root, changed, &settings).unwrap();
|
|
assert_eq!(
|
|
diff.lines
|
|
.iter()
|
|
.filter(|line| line.kind == GitDiffLineKind::Context)
|
|
.count(),
|
|
0
|
|
);
|
|
assert_eq!(
|
|
diff.lines
|
|
.iter()
|
|
.filter(|line| line.kind == GitDiffLineKind::Hunk)
|
|
.count(),
|
|
2
|
|
);
|
|
|
|
std::fs::remove_dir_all(directory).unwrap();
|
|
}
|
|
|
|
fn repository_fixture() -> PathBuf {
|
|
let directory = empty_repository_fixture();
|
|
std::fs::write(directory.join("changed.txt"), "original\n").unwrap();
|
|
std::fs::write(directory.join("deleted.txt"), "original\n").unwrap();
|
|
stage_paths(&directory, &["changed.txt".into(), "deleted.txt".into()]).unwrap();
|
|
commit_index(&directory, "Initial").unwrap();
|
|
directory
|
|
}
|
|
|
|
fn empty_repository_fixture() -> PathBuf {
|
|
let nonce = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let directory = std::env::temp_dir().join(format!(
|
|
"ds4-server-git-pane-{}-{nonce}-{}",
|
|
std::process::id(),
|
|
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
std::fs::create_dir(&directory).unwrap();
|
|
let mut options = git2::RepositoryInitOptions::new();
|
|
options.initial_head("main");
|
|
let repository = git2::Repository::init_opts(&directory, &options).unwrap();
|
|
configure_test_repository(&repository);
|
|
directory
|
|
}
|
|
|
|
fn configure_test_repository(repository: &git2::Repository) {
|
|
let mut config = repository.config().unwrap();
|
|
config.set_str("user.name", "DS4Server Test").unwrap();
|
|
config
|
|
.set_str("user.email", "test@ds4server.invalid")
|
|
.unwrap();
|
|
}
|
|
}
|