GitComet/crates/gitcomet-core/src/services.rs
2026-03-12 12:35:47 +02:00

729 lines
24 KiB
Rust

use crate::conflict_session::ConflictSession;
use crate::domain::*;
use crate::error::{Error, ErrorKind};
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct CommandOutput {
pub command: String,
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
}
impl CommandOutput {
pub fn empty_success(command: impl Into<String>) -> Self {
Self {
command: command.into(),
stdout: String::new(),
stderr: String::new(),
exit_code: Some(0),
}
}
pub fn combined(&self) -> String {
let mut out = String::new();
if !self.stdout.trim().is_empty() {
out.push_str(self.stdout.trim_end());
out.push('\n');
}
if !self.stderr.trim().is_empty() {
out.push_str(self.stderr.trim_end());
out.push('\n');
}
out.trim_end().to_string()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConflictSide {
Ours,
Theirs,
}
/// Result of launching an external mergetool.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MergetoolResult {
/// The tool command that was invoked.
pub tool_name: String,
/// Whether the tool reported success (exit code 0 or trust-exit-code semantics).
pub success: bool,
/// The merged file contents read back after the tool exited, if available.
pub merged_contents: Option<Vec<u8>>,
/// Combined stdout/stderr from the tool invocation for diagnostics.
pub output: CommandOutput,
}
/// Try to decode optional bytes as UTF-8. Returns `None` if the bytes are
/// `None` or not valid UTF-8.
pub fn decode_utf8_optional(bytes: Option<&[u8]>) -> Option<String> {
bytes.and_then(|b| std::str::from_utf8(b).ok().map(str::to_owned))
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ConflictTextValidation {
pub has_conflict_markers: bool,
pub marker_lines: usize,
}
/// Validate merged text before staging by scanning for unresolved
/// conflict marker lines.
pub fn validate_conflict_resolution_text(text: &str) -> ConflictTextValidation {
let marker_lines = text
.lines()
.filter(|line| {
line.starts_with("<<<<<<<")
|| line.starts_with(">>>>>>>")
|| line.starts_with("=======")
|| line.starts_with("|||||||")
})
.count();
ConflictTextValidation {
has_conflict_markers: marker_lines > 0,
marker_lines,
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConflictFileStages {
pub path: PathBuf,
pub base_bytes: Option<Vec<u8>>,
pub ours_bytes: Option<Vec<u8>>,
pub theirs_bytes: Option<Vec<u8>>,
pub base: Option<String>,
pub ours: Option<String>,
pub theirs: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ResetMode {
Soft,
Mixed,
Hard,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RemoteUrlKind {
Fetch,
Push,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BlameLine {
pub commit_id: String,
pub author: String,
pub author_time_unix: Option<i64>,
pub summary: String,
pub line: String,
}
pub trait GitRepository: Send + Sync {
fn spec(&self) -> &RepoSpec;
fn log_head_page(&self, limit: usize, cursor: Option<&LogCursor>) -> Result<LogPage>;
fn log_all_branches_page(&self, _limit: usize, _cursor: Option<&LogCursor>) -> Result<LogPage> {
Err(Error::new(ErrorKind::Unsupported(
"all-branches history is not implemented for this backend",
)))
}
fn log_file_page(
&self,
_path: &Path,
_limit: usize,
_cursor: Option<&LogCursor>,
) -> Result<LogPage> {
Err(Error::new(ErrorKind::Unsupported(
"file history is not implemented for this backend",
)))
}
fn commit_details(&self, id: &CommitId) -> Result<CommitDetails>;
fn reflog_head(&self, limit: usize) -> Result<Vec<ReflogEntry>>;
fn current_branch(&self) -> Result<String>;
fn list_branches(&self) -> Result<Vec<Branch>>;
fn list_tags(&self) -> Result<Vec<Tag>> {
Err(Error::new(ErrorKind::Unsupported(
"tag listing is not implemented for this backend",
)))
}
fn list_remote_tags(&self) -> Result<Vec<RemoteTag>> {
Err(Error::new(ErrorKind::Unsupported(
"remote tag listing is not implemented for this backend",
)))
}
fn list_remotes(&self) -> Result<Vec<Remote>>;
fn list_remote_branches(&self) -> Result<Vec<RemoteBranch>>;
fn status(&self) -> Result<RepoStatus>;
fn upstream_divergence(&self) -> Result<Option<UpstreamDivergence>> {
Ok(None)
}
fn diff_unified(&self, target: &DiffTarget) -> Result<String>;
/// Load and parse unified diff rows for the target.
///
/// Default implementation goes through `diff_unified`; backends may
/// override for streaming parsing to avoid large monolithic allocations.
fn diff_parsed(&self, target: &DiffTarget) -> Result<Diff> {
self.diff_unified(target)
.map(|text| Diff::from_unified(target.clone(), &text))
}
fn diff_file_text(&self, _target: &DiffTarget) -> Result<Option<FileDiffText>> {
Err(Error::new(ErrorKind::Unsupported(
"file diff view is not implemented for this backend",
)))
}
fn diff_file_image(&self, _target: &DiffTarget) -> Result<Option<FileDiffImage>> {
Err(Error::new(ErrorKind::Unsupported(
"image diff view is not implemented for this backend",
)))
}
fn conflict_file_stages(&self, _path: &Path) -> Result<Option<ConflictFileStages>> {
Err(Error::new(ErrorKind::Unsupported(
"conflict stage reading is not implemented for this backend",
)))
}
/// Build a backend-native conflict session for a conflicted path.
///
/// Backends that support conflict stages and conflict-kind detection should
/// return a populated session; unsupported backends return Unsupported.
fn conflict_session(&self, _path: &Path) -> Result<Option<ConflictSession>> {
Err(Error::new(ErrorKind::Unsupported(
"conflict session loading is not implemented for this backend",
)))
}
fn create_branch(&self, name: &str, target: &CommitId) -> Result<()>;
fn delete_branch(&self, name: &str) -> Result<()>;
fn delete_branch_force(&self, _name: &str) -> Result<()> {
Err(Error::new(ErrorKind::Unsupported(
"force branch deletion is not implemented for this backend",
)))
}
fn checkout_branch(&self, name: &str) -> Result<()>;
fn checkout_remote_branch(
&self,
_remote: &str,
_branch: &str,
_local_branch: &str,
) -> Result<()> {
Err(Error::new(ErrorKind::Unsupported(
"remote branch checkout is not implemented for this backend",
)))
}
fn checkout_commit(&self, id: &CommitId) -> Result<()>;
fn cherry_pick(&self, id: &CommitId) -> Result<()>;
fn revert(&self, id: &CommitId) -> Result<()>;
fn stash_create(&self, message: &str, include_untracked: bool) -> Result<()>;
fn stash_list(&self) -> Result<Vec<StashEntry>>;
fn stash_apply(&self, index: usize) -> Result<()>;
fn stash_drop(&self, index: usize) -> Result<()>;
fn stage(&self, paths: &[&Path]) -> Result<()>;
fn unstage(&self, paths: &[&Path]) -> Result<()>;
fn commit(&self, message: &str) -> Result<()>;
fn commit_amend(&self, _message: &str) -> Result<()> {
Err(Error::new(ErrorKind::Unsupported(
"commit amend is not implemented for this backend",
)))
}
fn rebase_with_output(&self, _onto: &str) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"git rebase is not implemented for this backend",
)))
}
fn rebase_continue_with_output(&self) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"git rebase --continue is not implemented for this backend",
)))
}
fn rebase_abort_with_output(&self) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"git rebase --abort is not implemented for this backend",
)))
}
fn merge_abort_with_output(&self) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"git merge --abort is not implemented for this backend",
)))
}
fn rebase_in_progress(&self) -> Result<bool> {
Ok(false)
}
fn merge_commit_message(&self) -> Result<Option<String>> {
Ok(None)
}
fn create_tag_with_output(&self, _name: &str, _target: &str) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"git tag creation is not implemented for this backend",
)))
}
fn delete_tag_with_output(&self, _name: &str) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"git tag deletion is not implemented for this backend",
)))
}
fn prune_merged_branches_with_output(&self) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"pruning merged branches is not implemented for this backend",
)))
}
fn prune_local_tags_with_output(&self) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"pruning local tags is not implemented for this backend",
)))
}
fn push_tag_with_output(&self, _remote: &str, _name: &str) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"pushing tags is not implemented for this backend",
)))
}
fn delete_remote_tag_with_output(&self, _remote: &str, _name: &str) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"remote tag deletion is not implemented for this backend",
)))
}
fn add_remote_with_output(&self, _name: &str, _url: &str) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"git remote add is not implemented for this backend",
)))
}
fn remove_remote_with_output(&self, _name: &str) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"git remote remove is not implemented for this backend",
)))
}
fn set_remote_url_with_output(
&self,
_name: &str,
_url: &str,
_kind: RemoteUrlKind,
) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"git remote set-url is not implemented for this backend",
)))
}
fn fetch_all(&self) -> Result<()>;
fn pull(&self, mode: PullMode) -> Result<()>;
fn push(&self) -> Result<()>;
fn push_force(&self) -> Result<()> {
Err(Error::new(ErrorKind::Unsupported(
"force push is not implemented for this backend",
)))
}
fn push_set_upstream(&self, _remote: &str, _branch: &str) -> Result<()> {
Err(Error::new(ErrorKind::Unsupported(
"pushing with --set-upstream is not implemented for this backend",
)))
}
fn fetch_all_with_output(&self) -> Result<CommandOutput> {
self.fetch_all()?;
Ok(CommandOutput::empty_success("git fetch --all"))
}
fn fetch_all_with_output_prune(&self, prune: bool) -> Result<CommandOutput> {
let _ = prune;
self.fetch_all_with_output()
}
fn pull_with_output(&self, mode: PullMode) -> Result<CommandOutput> {
self.pull(mode)?;
Ok(CommandOutput::empty_success("git pull"))
}
fn push_with_output(&self) -> Result<CommandOutput> {
self.push()?;
Ok(CommandOutput::empty_success("git push"))
}
fn push_force_with_output(&self) -> Result<CommandOutput> {
self.push_force()?;
Ok(CommandOutput::empty_success("git push --force-with-lease"))
}
fn push_set_upstream_with_output(&self, remote: &str, branch: &str) -> Result<CommandOutput> {
self.push_set_upstream(remote, branch)?;
Ok(CommandOutput::empty_success(format!(
"git push --set-upstream {remote} HEAD:refs/heads/{branch}"
)))
}
fn delete_remote_branch_with_output(
&self,
_remote: &str,
_branch: &str,
) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"remote branch deletion is not implemented for this backend",
)))
}
fn commit_amend_with_output(&self, message: &str) -> Result<CommandOutput> {
self.commit_amend(message)?;
Ok(CommandOutput::empty_success("git commit --amend"))
}
fn pull_branch_with_output(&self, _remote: &str, _branch: &str) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"pulling a specific remote branch is not implemented for this backend",
)))
}
fn merge_ref_with_output(&self, _reference: &str) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"merging a specific ref is not implemented for this backend",
)))
}
fn squash_ref_with_output(&self, _reference: &str) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"squashing a specific ref is not implemented for this backend",
)))
}
fn reset_with_output(&self, _target: &str, _mode: ResetMode) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"git reset is not implemented for this backend",
)))
}
fn blame_file(&self, _path: &Path, _rev: Option<&str>) -> Result<Vec<BlameLine>> {
Err(Error::new(ErrorKind::Unsupported(
"git blame is not implemented for this backend",
)))
}
fn checkout_conflict_side(&self, _path: &Path, _side: ConflictSide) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"conflict resolution is not implemented for this backend",
)))
}
/// Accept a conflict by explicitly deleting the path and staging removal.
///
/// Used by decision/keep-delete resolvers when the chosen outcome is
/// "accept deletion" rather than selecting a side's content.
fn accept_conflict_deletion(&self, _path: &Path) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"conflict deletion is not implemented for this backend",
)))
}
/// Restore a conflicted file from stage-1 (base) contents and stage it.
///
/// Useful for decision-style conflicts where users want to explicitly
/// recover the base version as the resolution result.
fn checkout_conflict_base(&self, _path: &Path) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"base conflict checkout is not implemented for this backend",
)))
}
/// Launch an external mergetool for a conflicted file.
///
/// Materializes BASE, LOCAL, REMOTE temp files from the conflict stages,
/// invokes the configured (or specified) mergetool, reads back the merged
/// output, writes it to the worktree, and stages the result.
fn launch_mergetool(&self, _path: &Path) -> Result<MergetoolResult> {
Err(Error::new(ErrorKind::Unsupported(
"external mergetool is not implemented for this backend",
)))
}
fn export_patch_with_output(
&self,
_commit_id: &CommitId,
_dest: &Path,
) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"patch export is not implemented for this backend",
)))
}
fn apply_patch_with_output(&self, _patch: &Path) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"patch apply is not implemented for this backend",
)))
}
fn apply_unified_patch_to_index_with_output(
&self,
_patch: &str,
_reverse: bool,
) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"index patch apply is not implemented for this backend",
)))
}
fn apply_unified_patch_to_worktree_with_output(
&self,
_patch: &str,
_reverse: bool,
) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"worktree patch apply is not implemented for this backend",
)))
}
fn list_worktrees(&self) -> Result<Vec<Worktree>> {
Err(Error::new(ErrorKind::Unsupported(
"worktree listing is not implemented for this backend",
)))
}
fn add_worktree_with_output(
&self,
_path: &Path,
_reference: Option<&str>,
) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"worktree add is not implemented for this backend",
)))
}
fn remove_worktree_with_output(&self, _path: &Path) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"worktree remove is not implemented for this backend",
)))
}
fn force_remove_worktree_with_output(&self, _path: &Path) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"worktree force remove is not implemented for this backend",
)))
}
fn list_submodules(&self) -> Result<Vec<Submodule>> {
Err(Error::new(ErrorKind::Unsupported(
"submodule listing is not implemented for this backend",
)))
}
fn add_submodule_with_output(&self, _url: &str, _path: &Path) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"submodule add is not implemented for this backend",
)))
}
fn update_submodules_with_output(&self) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"submodule update is not implemented for this backend",
)))
}
fn remove_submodule_with_output(&self, _path: &Path) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"submodule remove is not implemented for this backend",
)))
}
fn discard_worktree_changes(&self, paths: &[&Path]) -> Result<()>;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PullMode {
Default,
Merge,
FastForwardIfPossible,
FastForwardOnly,
Rebase,
}
pub trait GitBackend: Send + Sync {
fn open(&self, workdir: &Path) -> Result<Arc<dyn GitRepository>>;
}
#[cfg(test)]
mod tests {
use super::{CommandOutput, decode_utf8_optional, validate_conflict_resolution_text};
// ── validate_conflict_resolution_text ────────────────────────────
#[test]
fn validate_conflict_resolution_text_reports_no_markers() {
let validation = validate_conflict_resolution_text("line 1\nline 2\n");
assert!(!validation.has_conflict_markers);
assert_eq!(validation.marker_lines, 0);
}
#[test]
fn validate_conflict_resolution_text_counts_marker_lines() {
let text = "<<<<<<< ours\nx\n=======\ny\n>>>>>>> theirs\n";
let validation = validate_conflict_resolution_text(text);
assert!(validation.has_conflict_markers);
assert_eq!(validation.marker_lines, 3);
}
#[test]
fn validate_empty_text_reports_no_markers() {
let validation = validate_conflict_resolution_text("");
assert!(!validation.has_conflict_markers);
assert_eq!(validation.marker_lines, 0);
}
#[test]
fn validate_diff3_markers_detected() {
let text = "<<<<<<< ours\na\n||||||| base\nb\n=======\nc\n>>>>>>> theirs\n";
let validation = validate_conflict_resolution_text(text);
assert!(validation.has_conflict_markers);
assert_eq!(validation.marker_lines, 4);
}
#[test]
fn validate_markers_with_branch_annotations_detected() {
let text = "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> feature/my-branch\n";
let validation = validate_conflict_resolution_text(text);
assert!(validation.has_conflict_markers);
assert_eq!(validation.marker_lines, 3);
}
#[test]
fn validate_partial_marker_set_detected() {
// Only start marker — still detects it
let text = "some code\n<<<<<<< HEAD\nmore code\n";
let validation = validate_conflict_resolution_text(text);
assert!(validation.has_conflict_markers);
assert_eq!(validation.marker_lines, 1);
}
#[test]
fn validate_markers_not_at_start_of_line_ignored() {
// Markers must be at line start to count
let text = " <<<<<<< not a marker\n ======= not a marker\n";
let validation = validate_conflict_resolution_text(text);
assert!(!validation.has_conflict_markers);
assert_eq!(validation.marker_lines, 0);
}
#[test]
fn validate_multiple_conflicts_counts_all_markers() {
let text = "\
<<<<<<< HEAD\na\n=======\nb\n>>>>>>> branch1\n\
<<<<<<< HEAD\nc\n=======\nd\n>>>>>>> branch2\n";
let validation = validate_conflict_resolution_text(text);
assert!(validation.has_conflict_markers);
assert_eq!(validation.marker_lines, 6);
}
// ── decode_utf8_optional ─────────────────────────────────────────
#[test]
fn decode_utf8_none_returns_none() {
assert_eq!(decode_utf8_optional(None), None);
}
#[test]
fn decode_utf8_valid_returns_string() {
let bytes = b"hello world";
assert_eq!(
decode_utf8_optional(Some(bytes.as_slice())),
Some("hello world".to_string())
);
}
#[test]
fn decode_utf8_invalid_returns_none() {
let bytes = &[0xff, 0xfe, 0x00, 0x01];
assert_eq!(decode_utf8_optional(Some(bytes.as_slice())), None);
}
#[test]
fn decode_utf8_empty_bytes_returns_empty_string() {
let bytes: &[u8] = b"";
assert_eq!(decode_utf8_optional(Some(bytes)), Some(String::new()));
}
#[test]
fn decode_utf8_multibyte_chars_preserved() {
let text = "héllo wörld 日本語";
assert_eq!(
decode_utf8_optional(Some(text.as_bytes())),
Some(text.to_string())
);
}
// ── CommandOutput ────────────────────────────────────────────────
#[test]
fn command_output_empty_success_has_zero_exit_code() {
let out = CommandOutput::empty_success("git status");
assert_eq!(out.command, "git status");
assert_eq!(out.stdout, "");
assert_eq!(out.stderr, "");
assert_eq!(out.exit_code, Some(0));
}
#[test]
fn command_output_combined_stdout_only() {
let out = CommandOutput {
command: "test".into(),
stdout: "output line\n".into(),
stderr: String::new(),
exit_code: Some(0),
};
assert_eq!(out.combined(), "output line");
}
#[test]
fn command_output_combined_stderr_only() {
let out = CommandOutput {
command: "test".into(),
stdout: String::new(),
stderr: "error message\n".into(),
exit_code: Some(1),
};
assert_eq!(out.combined(), "error message");
}
#[test]
fn command_output_combined_both_streams() {
let out = CommandOutput {
command: "test".into(),
stdout: "output\n".into(),
stderr: "warning\n".into(),
exit_code: Some(0),
};
assert_eq!(out.combined(), "output\nwarning");
}
#[test]
fn command_output_combined_empty_when_both_blank() {
let out = CommandOutput {
command: "test".into(),
stdout: " \n".into(),
stderr: " \n".into(),
exit_code: Some(0),
};
assert_eq!(out.combined(), "");
}
#[test]
fn command_output_combined_trims_trailing_whitespace() {
let out = CommandOutput {
command: "test".into(),
stdout: "line1\nline2\n\n".into(),
stderr: "err\n\n".into(),
exit_code: Some(0),
};
assert_eq!(out.combined(), "line1\nline2\nerr");
}
#[test]
fn command_output_default_has_no_exit_code() {
let out = CommandOutput::default();
assert_eq!(out.command, "");
assert_eq!(out.exit_code, None);
}
}