Add Git worktree pane

This commit is contained in:
Georg Bauer
2026-07-27 19:44:12 +02:00
parent aebfe7aeb6
commit b9933ae076
9 changed files with 2630 additions and 5 deletions

930
src/app/git.rs Normal file
View File

@@ -0,0 +1,930 @@
use super::*;
use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::process::{Command, Output};
#[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, Default, Eq, PartialEq)]
pub(crate) enum GitDiffMode {
#[default]
Unified,
Split,
}
#[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) {
Ok(diff) => {
self.git_diff = Some(diff);
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 || {
run_git_paths(&root, &["add", "--"], &selected)?;
run_git_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 || {
run_git_args(&root, &["commit", "-m", &message]).map(|_| ())
});
} 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 || {
run_git_args(&root, &["add", "--all"])?;
run_git_args(&root, &["commit", "-m", &message]).map(|_| ())
});
}
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 = gix::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 dirwalk = repository
.dirwalk_options()
.map_err(|error| error.to_string())?;
let mut files = BTreeMap::<PathBuf, GitFile>::new();
let changes = repository
.status(gix::progress::Discard)
.map_err(|error| error.to_string())?
.index_worktree_options_mut(|options| options.dirwalk_options = Some(dirwalk))
.untracked_files(gix::status::UntrackedFiles::Files)
.into_iter(Vec::<gix::bstr::BString>::new())
.map_err(|error| error.to_string())?;
for change in changes {
let change = change.map_err(|error| error.to_string())?;
let path = gix::path::from_bstr(change.location()).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,
});
match change {
gix::status::Item::IndexWorktree(change) => {
file.worktree_kind = change.summary().map(worktree_kind);
}
gix::status::Item::TreeIndex(change) => {
file.staged_kind = Some(match change {
gix::diff::index::Change::Addition { .. } => GitChangeKind::Added,
gix::diff::index::Change::Deletion { .. } => GitChangeKind::Deleted,
gix::diff::index::Change::Modification { .. }
| gix::diff::index::Change::Rewrite { .. } => GitChangeKind::Modified,
});
}
}
}
files.retain(|_, file| file.staged_kind.is_some() || file.worktree_kind.is_some());
Ok(GitWorktree {
root,
files: files.into_values().collect(),
})
}
fn worktree_kind(summary: gix::status::index_worktree::iter::Summary) -> GitChangeKind {
use gix::status::index_worktree::iter::Summary;
match summary {
Summary::Added | Summary::IntentToAdd => GitChangeKind::Added,
Summary::Removed => GitChangeKind::Deleted,
Summary::Modified
| Summary::TypeChange
| Summary::Renamed
| Summary::Copied
| Summary::Conflict => GitChangeKind::Modified,
}
}
fn load_diff(root: &Path, file: &GitFile) -> Result<GitDiff, String> {
let mut sections = Vec::new();
if file.staged_kind.is_some() {
let output = run_git_path(
root,
&["diff", "--cached", "--no-ext-diff", "--no-color", "--"],
&file.path,
)?;
sections.push(("Staged changes", output));
}
if file.worktree_kind.is_some() {
let mut output = run_git_path(
root,
&["diff", "--no-ext-diff", "--no-color", "--"],
&file.path,
)?;
if output.is_empty() && file.worktree_kind == Some(GitChangeKind::Added) {
output = run_no_index_diff(root, &file.path)?;
}
sections.push(("Worktree changes", output));
}
let mut lines = Vec::new();
for (label, content) in sections {
lines.push(GitDiffLine {
kind: GitDiffLineKind::Section,
old_number: None,
new_number: None,
text: label.into(),
});
lines.extend(parse_unified_diff(&content));
}
if lines.is_empty() {
lines.push(GitDiffLine {
kind: GitDiffLineKind::Header,
old_number: None,
new_number: None,
text: "No textual diff is available.".into(),
});
}
Ok(GitDiff {
path: file.display_path.clone(),
lines,
})
}
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 git_has_head(root: &Path) -> bool {
Command::new("git")
.arg("-C")
.arg(root)
.args(["rev-parse", "--verify", "HEAD"])
.output()
.is_ok_and(|output| output.status.success())
}
fn stage_paths(root: &Path, paths: &[PathBuf]) -> Result<(), String> {
run_git_paths(root, &["add", "--"], paths).map(|_| ())
}
fn unstage_paths(root: &Path, paths: &[PathBuf]) -> Result<(), String> {
if git_has_head(root) {
run_git_paths(root, &["restore", "--staged", "--"], paths)?;
} else {
run_git_paths(
root,
&["rm", "--cached", "--quiet", "--ignore-unmatch", "--"],
paths,
)?;
}
Ok(())
}
fn run_git_commit_paths(root: &Path, message: &str, paths: &[PathBuf]) -> Result<(), String> {
let mut command = git_command(root);
command.args(["commit", "-m", message, "--"]);
command.args(paths);
finish_git(command.output(), "commit selected files").map(|_| ())
}
fn run_git_paths(root: &Path, arguments: &[&str], paths: &[PathBuf]) -> Result<String, String> {
let mut command = git_command(root);
command.args(arguments);
command.args(paths);
finish_git(
command.output(),
arguments.first().copied().unwrap_or("run Git"),
)
}
fn run_git_path(root: &Path, arguments: &[&str], path: &Path) -> Result<String, String> {
run_git_paths(root, arguments, &[path.to_path_buf()])
}
fn run_git_args(root: &Path, arguments: &[&str]) -> Result<String, String> {
let mut command = git_command(root);
command.args(arguments);
finish_git(
command.output(),
arguments.first().copied().unwrap_or("run Git"),
)
}
fn run_git_remote(root: &Path, action: &str, branch: Option<&str>) -> Result<(), String> {
let mut command = git_command(root);
match action {
"fetch" => {
command.args(["fetch", "origin"]);
}
"pull" => {
command.args([
"pull",
"--no-rebase",
"origin",
branch.ok_or_else(|| "The current branch is unavailable.".to_owned())?,
]);
}
"push" => {
command.args([
"push",
"origin",
&format!(
"HEAD:{}",
branch.ok_or_else(|| "The current branch is unavailable.".to_owned())?
),
]);
}
_ => return Err("The Git remote action is unavailable.".into()),
}
finish_git(command.output(), action).map(|_| ())
}
fn run_no_index_diff(root: &Path, path: &Path) -> Result<String, String> {
let mut command = git_command(root);
command.args(["diff", "--no-index", "--no-color", "--"]);
command.arg(OsStr::new("/dev/null"));
command.arg(root.join(path));
let output = command
.output()
.map_err(|error| format!("Could not run Git diff: {error}"))?;
if output.status.success() || output.status.code() == Some(1) {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
finish_git(Ok(output), "diff")
}
}
fn git_command(root: &Path) -> Command {
let mut command = Command::new("git");
command.arg("-C").arg(root).env("GIT_TERMINAL_PROMPT", "0");
command
}
fn finish_git(output: std::io::Result<Output>, action: &str) -> Result<String, String> {
let output = output.map_err(|error| format!("Could not {action}: {error}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
Err(if detail.is_empty() {
format!("Could not {action}.")
} else {
format!("Could not {action}: {detail}")
})
}
}
#[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 gitoxide_status_merges_staged_and_worktree_changes() {
let directory = repository_fixture();
std::fs::write(directory.join("added.txt"), "added\n").unwrap();
run_git_args(&directory, &["config", "status.showUntrackedFiles", "no"]).unwrap();
std::fs::write(directory.join("changed.txt"), "changed\n").unwrap();
run_git_args(&directory, &["add", "changed.txt"]).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).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();
run_git_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");
let output = Command::new("git")
.args(["init", "--bare"])
.arg(&remote)
.output()
.unwrap();
assert!(output.status.success());
run_git_args(
&local,
&["remote", "add", "origin", remote.to_str().unwrap()],
)
.unwrap();
run_git_remote(&local, "push", Some("main")).unwrap();
run_git_args(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]).unwrap();
let output = Command::new("git")
.arg("clone")
.arg(&remote)
.arg(&collaborator)
.output()
.unwrap();
assert!(output.status.success());
run_git_args(&collaborator, &["config", "user.name", "DS4Server Test"]).unwrap();
run_git_args(
&collaborator,
&["config", "user.email", "test@ds4server.invalid"],
)
.unwrap();
std::fs::write(collaborator.join("remote.txt"), "from origin\n").unwrap();
run_git_args(&collaborator, &["add", "remote.txt"]).unwrap();
run_git_args(&collaborator, &["commit", "-m", "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();
run_git_args(&local, &["add", "local.txt"]).unwrap();
run_git_args(&local, &["commit", "-m", "Local change"]).unwrap();
run_git_remote(&local, "push", Some("main")).unwrap();
assert_eq!(
run_git_args(&remote, &["show", "main:local.txt"])
.unwrap()
.trim(),
"to origin"
);
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
)));
}
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();
run_git_args(&directory, &["add", "changed.txt", "deleted.txt"]).unwrap();
run_git_args(&directory, &["commit", "-m", "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();
run_git_args(&directory, &["init", "-b", "main"]).unwrap();
run_git_args(&directory, &["config", "user.name", "DS4Server Test"]).unwrap();
run_git_args(
&directory,
&["config", "user.email", "test@ds4server.invalid"],
)
.unwrap();
directory
}
}