From 422f71c8add465ee5ed0a64eaa8e5674ceeddacc Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 19 Jul 2026 11:53:02 +0200 Subject: [PATCH] Implement the Git workflow --- README.md | 1 + RUST_PLAN_EXTENSION.md | 12 +- crates/bds-core/src/engine/git.rs | 1707 +++++++++++++++++ crates/bds-core/src/engine/mod.rs | 1 + crates/bds-core/src/engine/post.rs | 7 +- crates/bds-core/src/engine/script_rebuild.rs | 8 +- .../bds-core/src/engine/template_rebuild.rs | 8 +- crates/bds-core/tests/git_engine.rs | 199 ++ crates/bds-ui/src/app.rs | 123 +- crates/bds-ui/src/app/git_handlers.rs | 633 ++++++ crates/bds-ui/src/views/git.rs | 525 +++++ crates/bds-ui/src/views/mod.rs | 1 + crates/bds-ui/src/views/panel.rs | 50 +- crates/bds-ui/src/views/sidebar.rs | 6 +- crates/bds-ui/src/views/workspace.rs | 50 +- locales/ui/de.ftl | 31 +- locales/ui/en.ftl | 31 +- locales/ui/es.ftl | 31 +- locales/ui/fr.ftl | 31 +- locales/ui/it.ftl | 31 +- 20 files changed, 3441 insertions(+), 45 deletions(-) create mode 100644 crates/bds-core/src/engine/git.rs create mode 100644 crates/bds-core/tests/git_engine.rs create mode 100644 crates/bds-ui/src/app/git_handlers.rs create mode 100644 crates/bds-ui/src/views/git.rs diff --git a/README.md b/README.md index 5221f39..40f7d99 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ The project is under active development. Core blogging workflows are broadly ava - Local preview in the app or system browser. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating. - SSH-agent-based SCP or rsync publishing. +- Integrated Git workflow with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode. - Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; bDS2 keeps its separate `bds2://` bookmarklet protocol. RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from CDNs. The preview is served by the Rust application and displayed by the operating-system webview. diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index ed08b61..42e72e9 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -8,17 +8,19 @@ Status describes the current source code as of 2026-07-19. Core one-shot AI, pub ## Current Extension Status -### Git and richer validation — Partly done +### Git and richer validation — Complete Done: - Site, media, translation, and metadata-diff engines and UI views. +- `GitEngine` repository initialization and inspection, Git LFS image tracking, status, staged/unstaged and per-file/commit diffs, branch and rename-following file history, remote state, commit, fetch, fast-forward-only pull, push, cancellation, timeouts, and platform-specific authentication guidance. +- Localized Git sidebar, log panel, read-only inline/side-by-side diff tabs, live task output, airplane-mode gating, and post-pull reconciliation through the normal filesystem rebuild paths and typed events. + +### Synchronization scripting bridge — Open Open: -- `GitEngine`, repository status, history, diff view, commit, fetch, pull, push, and LFS command integration. -- Expose `bds.sync` after the shared Git/synchronization workflow exists. -- Replace the Git sidebar and Git log placeholders with working flows. +- Expose `bds.sync` through the scripting API using the shared Git workflow. ### WordPress import — Open @@ -121,7 +123,7 @@ Open: ## Suggested Order -1. Git workflow and WordPress import. +1. WordPress import. 2. CLI, MCP, and domain events. 3. Conversational AI and agent tools. 4. Embeddings and duplicate detection. diff --git a/crates/bds-core/src/engine/git.rs b/crates/bds-core/src/engine/git.rs new file mode 100644 index 0000000..ae734ab --- /dev/null +++ b/crates/bds-core/src/engine/git.rs @@ -0,0 +1,1707 @@ +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::db::DbConnection; +use crate::engine::{EngineError, EngineResult}; + +pub const LOCAL_TIMEOUT: Duration = Duration::from_secs(15); +pub const NETWORK_TIMEOUT: Duration = Duration::from_secs(120); +pub const FILE_HISTORY_LIMIT: usize = 50; + +const GITIGNORE_LINES: &[&str] = &[ + "/html/", + "/thumbnails/", + "/pagefind/", + "/.DS_Store", + "/node_modules/", + "/deps/", + "/_build/", +]; + +const LFS_PATTERNS: &[&str] = &[ + "*.jpg", "*.jpeg", "*.png", "*.gif", "*.webp", "*.svg", "*.tif", "*.tiff", "*.bmp", "*.heic", + "*.heif", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitProvider { + GitHub, + GitLab, + GiteaForgejo, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncStatus { + LocalOnly, + RemoteOnly, + Both, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileStatusKind { + Added, + Modified, + Deleted, + Renamed, + Untracked, +} + +impl FileStatusKind { + pub fn code(self) -> &'static str { + match self { + Self::Added => "A", + Self::Modified => "M", + Self::Deleted => "D", + Self::Renamed => "R", + Self::Untracked => "U", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitFileStatus { + pub path: String, + pub old_path: Option, + pub kind: FileStatusKind, + pub staged: bool, + pub unstaged: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitCommit { + pub hash: String, + pub subject: Option, + pub author: Option, + pub date: Option, + pub sync_status: SyncStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitRepository { + pub is_initialized: bool, + pub remote_url: Option, + pub provider: Option, + pub current_branch: Option, + pub has_lfs: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitRemoteState { + pub local_branch: Option, + pub upstream_branch: Option, + pub has_upstream: bool, + pub ahead: usize, + pub behind: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitDiff { + pub staged: String, + pub unstaged: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitFileDiff { + pub file_path: String, + pub original: String, + pub modified: String, + pub patch: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitOperation { + Initialize, + Status, + Diff, + History, + Remote, + Fetch, + Pull, + Push, + Commit, + Lfs, + Reconcile, +} + +impl fmt::Display for GitOperation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Initialize => "initialize", + Self::Status => "status", + Self::Diff => "diff", + Self::History => "history", + Self::Remote => "remote", + Self::Fetch => "fetch", + Self::Pull => "pull", + Self::Push => "push", + Self::Commit => "commit", + Self::Lfs => "LFS", + Self::Reconcile => "reconcile", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitPlatform { + MacOs, + Windows, + Linux, +} + +impl fmt::Display for GitPlatform { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::MacOs => "macOS", + Self::Windows => "Windows", + Self::Linux => "Linux", + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GitError { + Io { + operation: GitOperation, + message: String, + }, + Validation(String), + Failed { + operation: GitOperation, + output: String, + }, + TimedOut { + operation: GitOperation, + timeout: Duration, + output: String, + }, + Cancelled { + operation: GitOperation, + output: String, + }, + Authentication { + operation: GitOperation, + provider: Option, + platform: GitPlatform, + guidance: String, + }, +} + +impl fmt::Display for GitError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { operation, message } => write!(f, "Git {operation} failed: {message}"), + Self::Validation(message) => f.write_str(message), + Self::Failed { operation, output } => { + write!(f, "Git {operation} failed: {output}") + } + Self::TimedOut { + operation, timeout, .. + } => write!( + f, + "Git {operation} timed out after {}ms", + timeout.as_millis() + ), + Self::Cancelled { operation, .. } => write!(f, "Git {operation} was cancelled"), + Self::Authentication { guidance, .. } => f.write_str(guidance), + } + } +} + +impl std::error::Error for GitError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitOutputStream { + Stdout, + Stderr, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitOutput { + pub stream: GitOutputStream, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkResult { + pub output: String, +} + +#[derive(Debug, Clone)] +pub struct GitEngine { + repository_dir: PathBuf, + executable: PathBuf, + local_timeout: Duration, + network_timeout: Duration, +} + +impl GitEngine { + pub fn new(repository_dir: impl Into) -> Self { + Self { + repository_dir: repository_dir.into(), + executable: PathBuf::from("git"), + local_timeout: LOCAL_TIMEOUT, + network_timeout: NETWORK_TIMEOUT, + } + } + + pub fn repository(&self) -> Result { + if !self.repository_dir.join(".git").exists() { + return Ok(GitRepository { + is_initialized: false, + remote_url: None, + provider: None, + current_branch: None, + has_lfs: false, + }); + } + + let remote_url = + self.optional_output(&["remote", "get-url", "origin"], GitOperation::Remote)?; + Ok(GitRepository { + is_initialized: true, + provider: remote_url.as_deref().map(provider_from_url), + remote_url, + current_branch: self.current_branch()?, + has_lfs: self.has_lfs_tracking(), + }) + } + + pub fn initialize(&self) -> Result { + fs::create_dir_all(&self.repository_dir) + .map_err(|error| self.io_error(GitOperation::Initialize, error))?; + self.run_local(&["init", "-b", "master"], GitOperation::Initialize)?; + self.run_local(&["lfs", "install", "--local"], GitOperation::Lfs)?; + + let mut args = vec!["lfs", "track"]; + args.extend(LFS_PATTERNS.iter().copied()); + self.run_local(&args, GitOperation::Lfs)?; + append_missing_lines(&self.repository_dir.join(".gitignore"), GITIGNORE_LINES) + .map_err(|error| self.io_error(GitOperation::Initialize, error))?; + append_lfs_lines(&self.repository_dir.join(".gitattributes")) + .map_err(|error| self.io_error(GitOperation::Lfs, error))?; + self.repository() + } + + pub fn status(&self) -> Result, GitError> { + let output = self.run_local_bytes( + &["status", "--porcelain=v1", "-z", "--untracked-files=all"], + GitOperation::Status, + )?; + Ok(parse_status(&output)) + } + + pub fn diff(&self) -> Result { + Ok(GitDiff { + staged: self.run_local( + &["diff", "--cached", "--no-ext-diff", "--no-color"], + GitOperation::Diff, + )?, + unstaged: self + .run_local(&["diff", "--no-ext-diff", "--no-color"], GitOperation::Diff)?, + }) + } + + pub fn file_diff(&self, file_path: &str) -> Result { + validate_relative_path(file_path)?; + let revision = format!("HEAD:{file_path}"); + let original = self + .optional_raw_output(&["show", &revision], GitOperation::Diff)? + .unwrap_or_default(); + let modified = match fs::read(self.repository_dir.join(file_path)) { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(self.io_error(GitOperation::Diff, error)), + }; + let mut patch = self + .optional_raw_output( + &[ + "diff", + "HEAD", + "--no-ext-diff", + "--no-color", + "--", + file_path, + ], + GitOperation::Diff, + )? + .unwrap_or_default(); + if patch.is_empty() && original.is_empty() && !modified.is_empty() { + patch = added_file_patch(file_path, &modified); + } + Ok(GitFileDiff { + file_path: file_path.to_string(), + original, + modified, + patch, + }) + } + + pub fn commit_files(&self, hash: &str) -> Result, GitError> { + if !is_object_name(hash) { + return Err(GitError::Validation("invalid commit identifier".into())); + } + let output = self.run_local_bytes( + &[ + "diff-tree", + "--root", + "--no-commit-id", + "--name-status", + "-r", + "-z", + "--find-renames", + hash, + ], + GitOperation::Diff, + )?; + Ok(parse_changed_files(&output)) + } + + pub fn commit_file_diff( + &self, + hash: &str, + change: &ChangedFile, + ) -> Result { + if !is_object_name(hash) { + return Err(GitError::Validation("invalid commit identifier".into())); + } + validate_relative_path(&change.path)?; + if let Some(old_path) = &change.old_path { + validate_relative_path(old_path)?; + } + let old_path = change.old_path.as_deref().unwrap_or(&change.path); + let parent_revision = format!("{hash}^:{old_path}"); + let revision = format!("{hash}:{}", change.path); + let original = if change.kind == FileStatusKind::Added { + String::new() + } else { + self.run_local(&["show", &parent_revision], GitOperation::Diff)? + }; + let modified = if change.kind == FileStatusKind::Deleted { + String::new() + } else { + self.run_local(&["show", &revision], GitOperation::Diff)? + }; + let patch = self.run_local( + &[ + "show", + "--format=", + "--no-ext-diff", + "--no-color", + hash, + "--", + &change.path, + ], + GitOperation::Diff, + )?; + Ok(GitFileDiff { + file_path: change.path.clone(), + original, + modified, + patch, + }) + } + + pub fn history(&self, branch: &str) -> Result, GitError> { + if branch.trim().is_empty() { + return Ok(Vec::new()); + } + let local = self.history_for_revision_optional(branch)?; + let upstream = self.upstream_branch()?; + let remote = match upstream { + Some(upstream) => self.history_for_revision_optional(&upstream)?, + None => Vec::new(), + }; + let local_hashes = local + .iter() + .map(|commit| commit.hash.clone()) + .collect::>(); + let remote_hashes = remote + .iter() + .map(|commit| commit.hash.clone()) + .collect::>(); + let mut commits = local + .into_iter() + .map(|mut commit| { + commit.sync_status = if remote_hashes.contains(&commit.hash) { + SyncStatus::Both + } else { + SyncStatus::LocalOnly + }; + commit + }) + .collect::>(); + commits.extend(remote.into_iter().filter_map(|mut commit| { + (!local_hashes.contains(&commit.hash)).then(|| { + commit.sync_status = SyncStatus::RemoteOnly; + commit + }) + })); + Ok(commits) + } + + pub fn file_history(&self, file_path: &str) -> Result, GitError> { + validate_relative_path(file_path)?; + let limit = FILE_HISTORY_LIMIT.to_string(); + let output = self.optional_output( + &[ + "log", + "--follow", + "--date=short", + "--format=%H%x1f%an%x1f%ad%x1f%s%x1e", + "-n", + &limit, + "--", + file_path, + ], + GitOperation::History, + )?; + Ok(output.map_or_else(Vec::new, |output| parse_history(&output))) + } + + pub fn commit_diff(&self, hash: &str) -> Result { + if !is_object_name(hash) { + return Err(GitError::Validation("invalid commit identifier".into())); + } + self.run_local( + &[ + "show", + "--format=fuller", + "--no-ext-diff", + "--no-color", + hash, + ], + GitOperation::Diff, + ) + } + + pub fn remote_state(&self) -> Result { + let local_branch = self.current_branch()?; + let upstream_branch = self.upstream_branch()?; + let Some(upstream) = upstream_branch.clone() else { + return Ok(GitRemoteState { + local_branch, + upstream_branch: None, + has_upstream: false, + ahead: 0, + behind: 0, + }); + }; + Ok(GitRemoteState { + local_branch, + upstream_branch, + has_upstream: true, + ahead: self.revision_count(&format!("{upstream}..HEAD"))?, + behind: self.revision_count(&format!("HEAD..{upstream}"))?, + }) + } + + pub fn set_remote(&self, remote_url: &str) -> Result<(), GitError> { + let remote_url = remote_url.trim(); + if remote_url.is_empty() { + return Err(GitError::Validation("remote URL is required".into())); + } + let args = if self + .optional_output(&["remote", "get-url", "origin"], GitOperation::Remote)? + .is_some() + { + ["remote", "set-url", "origin", remote_url] + } else { + ["remote", "add", "origin", remote_url] + }; + self.run_local(&args, GitOperation::Remote).map(|_| ()) + } + + pub fn commit_all(&self, message: &str) -> Result { + let message = message.trim(); + if message.is_empty() { + return Err(GitError::Validation("commit message is required".into())); + } + self.run_local(&["add", "-A"], GitOperation::Commit)?; + let output = self.run_local(&["commit", "-m", message], GitOperation::Commit)?; + Ok(NetworkResult { output }) + } + + pub fn fetch( + &self, + is_cancelled: impl Fn() -> bool, + mut on_output: impl FnMut(GitOutput), + ) -> Result { + self.run_network( + &["fetch", "--all", "--prune", "--progress"], + GitOperation::Fetch, + &is_cancelled, + &mut on_output, + ) + } + + pub fn pull( + &self, + is_cancelled: impl Fn() -> bool, + mut on_output: impl FnMut(GitOutput), + ) -> Result { + self.run_network( + &["pull", "--ff-only", "--progress"], + GitOperation::Pull, + &is_cancelled, + &mut on_output, + ) + } + + pub fn push( + &self, + is_cancelled: impl Fn() -> bool, + mut on_output: impl FnMut(GitOutput), + ) -> Result { + self.run_network( + &["push", "--progress"], + GitOperation::Push, + &is_cancelled, + &mut on_output, + ) + } + + pub fn prune_lfs_cache(&self, retain_recent_days: u32) -> Result { + let retention = format!("lfs.fetchrecentcommitsdays={retain_recent_days}"); + let output = self.run_local( + &["-c", &retention, "lfs", "prune", "--recent"], + GitOperation::Lfs, + )?; + Ok(NetworkResult { output }) + } + + pub fn head(&self) -> Result, GitError> { + self.optional_output(&["rev-parse", "--verify", "HEAD"], GitOperation::History) + } + + pub fn changed_files( + &self, + old_commit: &str, + new_commit: &str, + ) -> Result, GitError> { + if !is_object_name(old_commit) || !is_object_name(new_commit) { + return Err(GitError::Validation("invalid commit identifier".into())); + } + let output = self.run_local_bytes( + &[ + "diff", + "--name-status", + "-z", + "--find-renames", + old_commit, + new_commit, + ], + GitOperation::Reconcile, + )?; + Ok(parse_changed_files(&output)) + } + + fn current_branch(&self) -> Result, GitError> { + self.optional_output( + &["symbolic-ref", "--quiet", "--short", "HEAD"], + GitOperation::History, + ) + } + + fn upstream_branch(&self) -> Result, GitError> { + self.optional_output( + &[ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ], + GitOperation::Remote, + ) + } + + fn revision_count(&self, range: &str) -> Result { + Ok(self + .optional_output(&["rev-list", "--count", range], GitOperation::History)? + .and_then(|value| value.parse().ok()) + .unwrap_or(0)) + } + + fn history_for_revision_optional(&self, revision: &str) -> Result, GitError> { + Ok(self + .optional_output( + &[ + "log", + "--date=short", + "--format=%H%x1f%an%x1f%ad%x1f%s%x1e", + revision, + ], + GitOperation::History, + )? + .map_or_else(Vec::new, |output| parse_history(&output))) + } + + fn has_lfs_tracking(&self) -> bool { + fs::read_to_string(self.repository_dir.join(".gitattributes")).is_ok_and(|contents| { + LFS_PATTERNS.iter().all(|pattern| { + contents + .lines() + .any(|line| line.starts_with(pattern) && line.contains("filter=lfs")) + }) + }) + } + + fn optional_output( + &self, + args: &[&str], + operation: GitOperation, + ) -> Result, GitError> { + match self.run_command(args, operation, self.local_timeout, &|| false, &mut |_| {}) { + Ok(result) if result.status == 0 => Ok(nonblank(result.stdout)), + Ok(_) => Ok(None), + Err(GitError::Failed { .. }) => Ok(None), + Err(error) => Err(error), + } + } + + fn optional_raw_output( + &self, + args: &[&str], + operation: GitOperation, + ) -> Result, GitError> { + match self.run_command(args, operation, self.local_timeout, &|| false, &mut |_| {}) { + Ok(result) if result.status == 0 => Ok(Some(result.stdout)), + Ok(_) | Err(GitError::Failed { .. }) => Ok(None), + Err(error) => Err(error), + } + } + + fn run_local(&self, args: &[&str], operation: GitOperation) -> Result { + let result = + self.run_command(args, operation, self.local_timeout, &|| false, &mut |_| {})?; + self.success_or_error(operation, result) + .map(|result| result.stdout) + } + + fn run_local_bytes(&self, args: &[&str], operation: GitOperation) -> Result, GitError> { + let result = + self.run_command(args, operation, self.local_timeout, &|| false, &mut |_| {})?; + self.success_or_error(operation, result) + .map(|result| result.stdout_bytes) + } + + fn run_network( + &self, + args: &[&str], + operation: GitOperation, + is_cancelled: &dyn Fn() -> bool, + on_output: &mut dyn FnMut(GitOutput), + ) -> Result { + let result = self.run_command( + args, + operation, + self.network_timeout, + is_cancelled, + on_output, + )?; + match self.success_or_error(operation, result) { + Ok(result) => Ok(NetworkResult { + output: combined_output(&result.stdout, &result.stderr), + }), + Err(GitError::Failed { output, .. }) if is_auth_error(&output) => { + let provider = self + .optional_output(&["remote", "get-url", "origin"], GitOperation::Remote)? + .as_deref() + .map(provider_from_url); + let platform = current_platform(); + Err(GitError::Authentication { + operation, + provider, + platform, + guidance: auth_guidance(provider, platform), + }) + } + Err(error) => Err(error), + } + } + + fn run_command( + &self, + args: &[&str], + operation: GitOperation, + timeout: Duration, + is_cancelled: &dyn Fn() -> bool, + on_output: &mut dyn FnMut(GitOutput), + ) -> Result { + let mut command = Command::new(&self.executable); + command + .args(args) + .current_dir(&self.repository_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GCM_INTERACTIVE", "never") + .env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes") + .env("GIT_LFS_SKIP_SMUDGE", "1") + .env("LC_ALL", "C") + .env("LANG", "C"); + configure_process_group(&mut command); + prepend_tool_paths(&mut command); + + let mut child = command + .spawn() + .map_err(|error| self.io_error(operation, error))?; + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let (sender, receiver) = mpsc::channel(); + let stdout_thread = read_stream(stdout, GitOutputStream::Stdout, sender.clone()); + let stderr_thread = read_stream(stderr, GitOutputStream::Stderr, sender); + let start = Instant::now(); + let mut stdout_bytes = Vec::new(); + let mut stderr_bytes = Vec::new(); + + loop { + drain_output(&receiver, &mut stdout_bytes, &mut stderr_bytes, on_output); + if is_cancelled() { + terminate_child(&mut child); + let _ = child.wait(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + drain_output(&receiver, &mut stdout_bytes, &mut stderr_bytes, on_output); + return Err(GitError::Cancelled { + operation, + output: combined_bytes(&stdout_bytes, &stderr_bytes), + }); + } + if start.elapsed() >= timeout { + terminate_child(&mut child); + let _ = child.wait(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + drain_output(&receiver, &mut stdout_bytes, &mut stderr_bytes, on_output); + return Err(GitError::TimedOut { + operation, + timeout, + output: combined_bytes(&stdout_bytes, &stderr_bytes), + }); + } + match child.try_wait() { + Ok(Some(status)) => { + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + drain_output(&receiver, &mut stdout_bytes, &mut stderr_bytes, on_output); + return Ok(CommandResult { + status: status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(), + stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(), + stdout_bytes, + }); + } + Ok(None) => thread::sleep(Duration::from_millis(10)), + Err(error) => return Err(self.io_error(operation, error)), + } + } + } + + fn success_or_error( + &self, + operation: GitOperation, + result: CommandResult, + ) -> Result { + if result.status == 0 { + Ok(result) + } else { + Err(GitError::Failed { + operation, + output: combined_output(&result.stdout, &result.stderr), + }) + } + } + + fn io_error(&self, operation: GitOperation, error: std::io::Error) -> GitError { + GitError::Io { + operation, + message: error.to_string(), + } + } + + #[cfg(test)] + fn with_executable_and_timeouts( + repository_dir: impl Into, + executable: impl Into, + local_timeout: Duration, + network_timeout: Duration, + ) -> Self { + Self { + repository_dir: repository_dir.into(), + executable: executable.into(), + local_timeout, + network_timeout, + } + } +} + +struct CommandResult { + status: i32, + stdout: String, + stderr: String, + stdout_bytes: Vec, +} + +fn read_stream( + mut stream: impl Read + Send + 'static, + kind: GitOutputStream, + sender: mpsc::Sender<(GitOutputStream, Vec)>, +) -> thread::JoinHandle<()> { + thread::spawn(move || { + let mut buffer = [0_u8; 4096]; + loop { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(read) => { + if sender.send((kind, buffer[..read].to_vec())).is_err() { + break; + } + } + } + } + }) +} + +fn drain_output( + receiver: &mpsc::Receiver<(GitOutputStream, Vec)>, + stdout: &mut Vec, + stderr: &mut Vec, + on_output: &mut dyn FnMut(GitOutput), +) { + while let Ok((stream, bytes)) = receiver.try_recv() { + match stream { + GitOutputStream::Stdout => stdout.extend_from_slice(&bytes), + GitOutputStream::Stderr => stderr.extend_from_slice(&bytes), + } + on_output(GitOutput { + stream, + text: String::from_utf8_lossy(&bytes).into_owned(), + }); + } +} + +#[cfg(unix)] +fn configure_process_group(command: &mut Command) { + use std::os::unix::process::CommandExt; + command.process_group(0); +} + +#[cfg(not(unix))] +fn configure_process_group(_command: &mut Command) {} + +#[cfg(unix)] +fn terminate_child(child: &mut std::process::Child) { + let group = format!("-{}", child.id()); + let _ = Command::new("kill").args(["-TERM", &group]).status(); + thread::sleep(Duration::from_millis(25)); + if child.try_wait().ok().flatten().is_none() { + let _ = Command::new("kill").args(["-KILL", &group]).status(); + } +} + +#[cfg(windows)] +fn terminate_child(child: &mut std::process::Child) { + let _ = Command::new("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .status(); + let _ = child.kill(); +} + +fn prepend_tool_paths(command: &mut Command) { + #[cfg(target_os = "macos")] + { + let mut paths = vec![ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + ]; + paths.extend( + std::env::var_os("PATH") + .iter() + .flat_map(std::env::split_paths), + ); + if let Ok(path) = std::env::join_paths(paths) { + command.env("PATH", path); + } + } +} + +fn append_missing_lines(path: &Path, required: &[&str]) -> std::io::Result<()> { + let existing = fs::read_to_string(path).unwrap_or_default(); + let lines = existing.lines().collect::>(); + let missing = required + .iter() + .copied() + .filter(|line| !lines.contains(line)) + .collect::>(); + if missing.is_empty() { + return Ok(()); + } + let mut output = existing; + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(&missing.join("\n")); + output.push('\n'); + fs::write(path, output) +} + +fn append_lfs_lines(path: &Path) -> std::io::Result<()> { + let required = LFS_PATTERNS + .iter() + .map(|pattern| format!("{pattern} filter=lfs diff=lfs merge=lfs -text")) + .collect::>(); + let refs = required.iter().map(String::as_str).collect::>(); + append_missing_lines(path, &refs) +} + +fn parse_status(output: &[u8]) -> Vec { + let fields = output.split(|byte| *byte == 0).collect::>(); + let mut files = Vec::new(); + let mut index = 0; + while index < fields.len() { + let field = fields[index]; + if field.len() < 4 { + index += 1; + continue; + } + let x = field[0] as char; + let y = field[1] as char; + let path = String::from_utf8_lossy(&field[3..]).into_owned(); + let renamed = matches!(x, 'R' | 'C') || matches!(y, 'R' | 'C'); + let old_path = if renamed { + index += 1; + fields + .get(index) + .filter(|old| !old.is_empty()) + .map(|old| String::from_utf8_lossy(old).into_owned()) + } else { + None + }; + let kind = if x == '?' && y == '?' { + FileStatusKind::Untracked + } else if renamed { + FileStatusKind::Renamed + } else if x == 'A' || y == 'A' { + FileStatusKind::Added + } else if x == 'D' || y == 'D' { + FileStatusKind::Deleted + } else { + FileStatusKind::Modified + }; + files.push(GitFileStatus { + path, + old_path, + kind, + staged: x != ' ' && x != '?', + unstaged: (y != ' ' && y != '?') || (x == '?' && y == '?'), + }); + index += 1; + } + files +} + +fn parse_history(output: &str) -> Vec { + output + .split('\x1e') + .filter_map(|record| { + let fields = record.trim().splitn(4, '\x1f').collect::>(); + (!fields.first().copied().unwrap_or_default().is_empty()).then(|| GitCommit { + hash: fields[0].to_string(), + author: optional_field(fields.get(1).copied()), + date: optional_field(fields.get(2).copied()), + subject: optional_field(fields.get(3).copied()), + sync_status: SyncStatus::LocalOnly, + }) + }) + .collect() +} + +fn optional_field(field: Option<&str>) -> Option { + field + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn provider_from_url(remote_url: &str) -> GitProvider { + let url = remote_url.to_ascii_lowercase(); + if url.contains("github.com") { + GitProvider::GitHub + } else if url.contains("gitlab") { + GitProvider::GitLab + } else { + GitProvider::GiteaForgejo + } +} + +fn current_platform() -> GitPlatform { + if cfg!(target_os = "macos") { + GitPlatform::MacOs + } else if cfg!(windows) { + GitPlatform::Windows + } else { + GitPlatform::Linux + } +} + +fn auth_guidance(provider: Option, platform: GitPlatform) -> String { + let provider = match provider { + Some(GitProvider::GitHub) => "GitHub", + Some(GitProvider::GitLab) => "GitLab", + Some(GitProvider::GiteaForgejo) => "Gitea/Forgejo", + None => "the Git provider", + }; + let credential = match platform { + GitPlatform::MacOs => "Keychain credential helper or an SSH key", + GitPlatform::Windows => "Git Credential Manager or an SSH key", + GitPlatform::Linux => "credential helper or an SSH key", + }; + format!( + "Authentication failed for {provider} on {platform}. Configure a {credential} that works non-interactively." + ) +} + +fn is_auth_error(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + [ + "authentication failed", + "permission denied", + "could not read username", + "terminal prompts disabled", + "repository not found", + "http basic: access denied", + ] + .iter() + .any(|needle| message.contains(needle)) +} + +fn nonblank(value: String) -> Option { + let value = value.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +fn combined_output(stdout: &str, stderr: &str) -> String { + match (stdout.trim(), stderr.trim()) { + ("", stderr) => stderr.to_string(), + (stdout, "") => stdout.to_string(), + (stdout, stderr) => format!("{stdout}\n{stderr}"), + } +} + +fn combined_bytes(stdout: &[u8], stderr: &[u8]) -> String { + combined_output( + &String::from_utf8_lossy(stdout), + &String::from_utf8_lossy(stderr), + ) +} + +fn added_file_patch(path: &str, contents: &str) -> String { + let mut patch = format!("diff --git a/{path} b/{path}\n--- /dev/null\n+++ b/{path}\n"); + patch.push_str( + &contents + .lines() + .map(|line| format!("+{line}")) + .collect::>() + .join("\n"), + ); + if contents.ends_with('\n') { + patch.push('\n'); + } + patch +} + +fn validate_relative_path(path: &str) -> Result<(), GitError> { + let path = Path::new(path); + if path.as_os_str().is_empty() + || path.is_absolute() + || !path + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return Err(GitError::Validation("invalid repository path".into())); + } + Ok(()) +} + +fn is_object_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.chars().all(|character| character.is_ascii_hexdigit()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangedFile { + pub path: String, + pub old_path: Option, + pub kind: FileStatusKind, +} + +fn parse_changed_files(output: &[u8]) -> Vec { + let fields = output.split(|byte| *byte == 0).collect::>(); + let mut changes = Vec::new(); + let mut index = 0; + while index < fields.len() { + let status = String::from_utf8_lossy(fields[index]); + if status.is_empty() { + break; + } + index += 1; + let Some(path) = fields.get(index).filter(|path| !path.is_empty()) else { + break; + }; + let first_path = String::from_utf8_lossy(path).into_owned(); + index += 1; + let code = status.as_bytes()[0] as char; + if matches!(code, 'R' | 'C') { + let Some(new_path) = fields.get(index).filter(|path| !path.is_empty()) else { + break; + }; + changes.push(ChangedFile { + path: String::from_utf8_lossy(new_path).into_owned(), + old_path: Some(first_path), + kind: FileStatusKind::Renamed, + }); + index += 1; + } else { + changes.push(ChangedFile { + path: first_path, + old_path: None, + kind: match code { + 'A' => FileStatusKind::Added, + 'D' => FileStatusKind::Deleted, + _ => FileStatusKind::Modified, + }, + }); + } + } + changes +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReconcileEntityType { + Post, + PostTranslation, + Script, + Template, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReconcileAction { + Created, + Updated, + Deleted, + Renamed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReconcileEvent { + pub project_id: String, + pub entity_type: ReconcileEntityType, + pub entity_id: String, + pub action: ReconcileAction, + pub path: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ReconcileReport { + pub events: Vec, +} + +#[derive(Debug, Clone)] +struct EntityAtPath { + entity_type: ReconcileEntityType, + id: String, +} + +pub fn reconcile_changed_files( + conn: &DbConnection, + data_dir: &Path, + project_id: &str, + changes: &[ChangedFile], + mut emit: impl FnMut(&ReconcileEvent), +) -> EngineResult { + let before = entities_by_path(conn, project_id)?; + let posts_changed = changes.iter().any(|change| relevant_path(change, "posts/")); + let scripts_changed = changes + .iter() + .any(|change| relevant_path(change, "scripts/")); + let templates_changed = changes + .iter() + .any(|change| relevant_path(change, "templates/")); + + if posts_changed { + let report = + crate::engine::post::rebuild_posts_from_filesystem(conn, data_dir, project_id)?; + fail_rebuild_errors("posts", report.errors)?; + } + if scripts_changed { + let report = crate::engine::script_rebuild::rebuild_scripts_from_filesystem( + conn, data_dir, project_id, + )?; + fail_rebuild_errors("scripts", report.errors)?; + } + if templates_changed { + let report = crate::engine::template_rebuild::rebuild_templates_from_filesystem( + conn, data_dir, project_id, + )?; + fail_rebuild_errors("templates", report.errors)?; + } + + let rebuilt = entities_by_path(conn, project_id)?; + for change in changes { + match change.kind { + FileStatusKind::Deleted => { + delete_entity_at_path(conn, before.get(&change.path))?; + } + FileStatusKind::Renamed => { + if let Some(old_path) = change.old_path.as_deref() { + let entity = before.get(old_path).filter(|entity| { + !rebuilt.values().any(|current| { + current.id == entity.id && current.entity_type == entity.entity_type + }) + }); + delete_entity_at_path(conn, entity)?; + } + } + _ => {} + } + } + if posts_changed { + crate::engine::post::rebuild_all_links(conn, data_dir, project_id)?; + } + + let after = entities_by_path(conn, project_id)?; + let mut events = Vec::new(); + for change in changes { + if !is_reconciled_path(&change.path) + && !change.old_path.as_deref().is_some_and(is_reconciled_path) + { + continue; + } + let event = match change.kind { + FileStatusKind::Deleted => before.get(&change.path).map(|entity| ReconcileEvent { + project_id: project_id.to_string(), + entity_type: entity.entity_type, + entity_id: entity.id.clone(), + action: ReconcileAction::Deleted, + path: change.path.clone(), + }), + FileStatusKind::Renamed => after.get(&change.path).map(|entity| ReconcileEvent { + project_id: project_id.to_string(), + entity_type: entity.entity_type, + entity_id: entity.id.clone(), + action: ReconcileAction::Renamed, + path: change.path.clone(), + }), + FileStatusKind::Added | FileStatusKind::Modified | FileStatusKind::Untracked => { + after.get(&change.path).map(|entity| ReconcileEvent { + project_id: project_id.to_string(), + entity_type: entity.entity_type, + entity_id: entity.id.clone(), + action: if before.values().any(|old| old.id == entity.id) { + ReconcileAction::Updated + } else { + ReconcileAction::Created + }, + path: change.path.clone(), + }) + } + }; + if let Some(event) = event { + emit(&event); + events.push(event); + } + } + Ok(ReconcileReport { events }) +} + +fn relevant_path(change: &ChangedFile, prefix: &str) -> bool { + change.path.starts_with(prefix) + || change + .old_path + .as_deref() + .is_some_and(|path| path.starts_with(prefix)) +} + +fn is_reconciled_path(path: &str) -> bool { + (path.starts_with("posts/") && path.ends_with(".md")) + || (path.starts_with("scripts/") && path.ends_with(".lua")) + || (path.starts_with("templates/") && path.ends_with(".liquid")) +} + +fn fail_rebuild_errors(category: &str, errors: Vec) -> EngineResult<()> { + if errors.is_empty() { + Ok(()) + } else { + Err(EngineError::Parse(format!( + "Git reconciliation failed for {category}: {}", + errors.join("; ") + ))) + } +} + +fn entities_by_path( + conn: &DbConnection, + project_id: &str, +) -> EngineResult> { + let mut entities = HashMap::new(); + for post in crate::db::queries::post::list_posts_by_project(conn, project_id)? { + if !post.file_path.is_empty() { + entities.insert( + post.file_path.clone(), + EntityAtPath { + entity_type: ReconcileEntityType::Post, + id: post.id.clone(), + }, + ); + } + for translation in + crate::db::queries::post_translation::list_post_translations_by_post(conn, &post.id)? + { + if !translation.file_path.is_empty() { + entities.insert( + translation.file_path, + EntityAtPath { + entity_type: ReconcileEntityType::PostTranslation, + id: translation.id, + }, + ); + } + } + } + for script in crate::db::queries::script::list_scripts_by_project(conn, project_id)? { + if !script.file_path.is_empty() { + entities.insert( + script.file_path, + EntityAtPath { + entity_type: ReconcileEntityType::Script, + id: script.id, + }, + ); + } + } + for template in crate::db::queries::template::list_templates_by_project(conn, project_id)? { + if !template.file_path.is_empty() { + entities.insert( + template.file_path, + EntityAtPath { + entity_type: ReconcileEntityType::Template, + id: template.id, + }, + ); + } + } + Ok(entities) +} + +fn delete_entity_at_path(conn: &DbConnection, entity: Option<&EntityAtPath>) -> EngineResult<()> { + let Some(entity) = entity else { + return Ok(()); + }; + match entity.entity_type { + ReconcileEntityType::Post => crate::db::queries::post::delete_post(conn, &entity.id)?, + ReconcileEntityType::PostTranslation => { + crate::db::queries::post_translation::delete_post_translation(conn, &entity.id)? + } + ReconcileEntityType::Script => crate::db::queries::script::delete_script(conn, &entity.id)?, + ReconcileEntityType::Template => { + crate::db::queries::template::delete_template(conn, &entity.id)? + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + + #[test] + fn parses_nul_status_with_renames_and_spaces() { + let files = + parse_status(b"M staged name.md\0 M work.md\0?? new file.md\0R new.md\0old.md\0"); + assert_eq!(files.len(), 4); + assert!(files[0].staged); + assert!(files[1].unstaged); + assert_eq!(files[2].kind, FileStatusKind::Untracked); + assert_eq!(files[3].path, "new.md"); + assert_eq!(files[3].old_path.as_deref(), Some("old.md")); + } + + #[test] + fn parses_name_status_renames() { + assert_eq!( + parse_changed_files(b"A\0posts/new.md\0R100\0scripts/old.lua\0scripts/new.lua\0"), + vec![ + ChangedFile { + path: "posts/new.md".into(), + old_path: None, + kind: FileStatusKind::Added, + }, + ChangedFile { + path: "scripts/new.lua".into(), + old_path: Some("scripts/old.lua".into()), + kind: FileStatusKind::Renamed, + }, + ] + ); + } + + #[test] + fn rejects_paths_outside_repository() { + assert!(validate_relative_path("../secret").is_err()); + assert!(validate_relative_path("/secret").is_err()); + assert!(validate_relative_path("posts/ok.md").is_ok()); + } + + #[test] + fn reconciliation_reuses_rebuild_paths_for_updates_deletes_and_renames() { + use crate::db::Database; + use crate::db::queries::project::{insert_project, make_test_project}; + use crate::model::{ScriptKind, TemplateKind}; + + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + crate::db::fts::ensure_fts_tables(db.conn()).unwrap(); + insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap(); + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("posts")).unwrap(); + fs::create_dir_all(dir.path().join("scripts")).unwrap(); + fs::create_dir_all(dir.path().join("templates")).unwrap(); + + let post = crate::engine::post::create_post( + db.conn(), + dir.path(), + "p1", + "Original title", + Some("Body"), + Vec::new(), + Vec::new(), + None, + Some("en"), + None, + ) + .unwrap(); + let post = crate::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap(); + let script = crate::engine::script::create_script( + db.conn(), + "p1", + "Tool", + ScriptKind::Utility, + "function main() return true end", + Some("main"), + ) + .unwrap(); + let script = + crate::engine::script::publish_script(db.conn(), dir.path(), &script.id).unwrap(); + let template = crate::engine::template::create_template( + db.conn(), + "p1", + "Layout", + TemplateKind::Post, + "
{{ content }}
", + ) + .unwrap(); + let template = + crate::engine::template::publish_template(db.conn(), dir.path(), &template.id).unwrap(); + + let post_path = dir.path().join(&post.file_path); + let post_contents = fs::read_to_string(&post_path).unwrap(); + fs::write( + &post_path, + post_contents.replace("title: Original title", "title: Pulled title"), + ) + .unwrap(); + fs::remove_file(dir.path().join(&script.file_path)).unwrap(); + let old_template_path = template.file_path.clone(); + let new_template_path = "templates/renamed-layout.liquid".to_string(); + fs::rename( + dir.path().join(&old_template_path), + dir.path().join(&new_template_path), + ) + .unwrap(); + + let changes = vec![ + ChangedFile { + path: post.file_path.clone(), + old_path: None, + kind: FileStatusKind::Modified, + }, + ChangedFile { + path: script.file_path.clone(), + old_path: None, + kind: FileStatusKind::Deleted, + }, + ChangedFile { + path: new_template_path.clone(), + old_path: Some(old_template_path), + kind: FileStatusKind::Renamed, + }, + ]; + let mut emitted = Vec::new(); + let report = reconcile_changed_files(db.conn(), dir.path(), "p1", &changes, |event| { + emitted.push(event.clone()); + }) + .unwrap(); + + assert_eq!(report.events, emitted); + assert_eq!( + crate::db::queries::post::get_post_by_id(db.conn(), &post.id) + .unwrap() + .title, + "Pulled title" + ); + assert!(crate::db::queries::script::get_script_by_id(db.conn(), &script.id).is_err()); + assert_eq!( + crate::db::queries::template::get_template_by_id(db.conn(), &template.id) + .unwrap() + .file_path, + new_template_path + ); + assert!(report.events.iter().any(|event| { + event.entity_id == script.id && event.action == ReconcileAction::Deleted + })); + assert!(report.events.iter().any(|event| { + event.entity_id == template.id && event.action == ReconcileAction::Renamed + })); + let diff = crate::engine::metadata_diff::compute_metadata_diff(db.conn(), dir.path(), "p1") + .unwrap(); + assert!( + diff.diffs.is_empty(), + "metadata differences: {:?}", + diff.diffs + ); + } + + #[cfg(unix)] + #[test] + fn initialization_preserves_ignore_entries_and_configures_every_lfs_pattern() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(".gitignore"), "/custom/\n").unwrap(); + let executable = dir.path().join("fake-git"); + fs::write( + &executable, + "#!/bin/sh\nif [ \"$1\" = init ]; then mkdir -p .git; exit 0; fi\nif [ \"$1\" = symbolic-ref ]; then echo master; exit 0; fi\nif [ \"$1\" = remote ]; then exit 2; fi\nexit 0\n", + ) + .unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + let engine = GitEngine::with_executable_and_timeouts( + dir.path(), + executable, + Duration::from_secs(1), + Duration::from_secs(1), + ); + + let repository = engine.initialize().unwrap(); + + assert!(repository.is_initialized); + assert_eq!(repository.current_branch.as_deref(), Some("master")); + let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!(gitignore.contains("/custom/")); + assert!( + GITIGNORE_LINES + .iter() + .all(|line| gitignore.lines().any(|found| found == *line)) + ); + let attributes = fs::read_to_string(dir.path().join(".gitattributes")).unwrap(); + assert!(LFS_PATTERNS.iter().all(|pattern| { + attributes + .lines() + .any(|line| line.starts_with(pattern) && line.contains("filter=lfs")) + })); + } + + #[cfg(unix)] + #[test] + fn timeout_and_cancellation_kill_the_process_and_preserve_output() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let executable = dir.path().join("fake-git"); + fs::write(&executable, "#!/bin/sh\nprintf 'started\\n'\nsleep 5\n").unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + let engine = GitEngine::with_executable_and_timeouts( + dir.path(), + &executable, + Duration::from_secs(1), + Duration::from_secs(1), + ); + let error = engine.fetch(|| false, |_| {}).unwrap_err(); + assert!( + matches!( + error, + GitError::TimedOut { + operation: GitOperation::Fetch, + ref output, + .. + } if output.contains("started") + ), + "{error:?}" + ); + + let cancelled = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&cancelled); + let output = Arc::new(Mutex::new(String::new())); + let streamed = Arc::clone(&output); + let engine = GitEngine::with_executable_and_timeouts( + dir.path(), + executable, + Duration::from_secs(1), + Duration::from_secs(1), + ); + let error = engine + .push( + move || { + let seen = !streamed.lock().unwrap().is_empty(); + if seen { + flag.store(true, Ordering::Release); + } + flag.load(Ordering::Acquire) + }, + |chunk| output.lock().unwrap().push_str(&chunk.text), + ) + .unwrap_err(); + assert!(matches!(error, GitError::Cancelled { .. })); + assert!(output.lock().unwrap().contains("started")); + } + + #[cfg(unix)] + #[test] + fn authentication_errors_are_structured_by_provider_and_platform() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let executable = dir.path().join("fake-git"); + fs::write( + &executable, + "#!/bin/sh\nif [ \"$1\" = remote ]; then echo git@gitlab.com:owner/repo.git; exit 0; fi\necho 'fatal: Authentication failed' >&2\nexit 128\n", + ) + .unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + let engine = GitEngine::with_executable_and_timeouts( + dir.path(), + executable, + Duration::from_secs(1), + Duration::from_secs(1), + ); + let error = engine.fetch(|| false, |_| {}).unwrap_err(); + assert!(matches!( + error, + GitError::Authentication { + provider: Some(GitProvider::GitLab), + .. + } + )); + } +} diff --git a/crates/bds-core/src/engine/mod.rs b/crates/bds-core/src/engine/mod.rs index 58a4fa5..e9260d0 100644 --- a/crates/bds-core/src/engine/mod.rs +++ b/crates/bds-core/src/engine/mod.rs @@ -5,6 +5,7 @@ pub mod calendar; pub mod error; pub mod gallery_import; pub mod generation; +pub mod git; pub mod media; pub mod menu; pub mod meta; diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index 96bf909..795fc05 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -929,8 +929,6 @@ pub(crate) fn rebuild_canonical_post( .to_string(); let hash = content_hash(content.as_bytes()); - let now = now_unix_ms(); - let status = match fm.status.as_str() { "published" => PostStatus::Published, "archived" => PostStatus::Archived, @@ -960,7 +958,7 @@ pub(crate) fn rebuild_canonical_post( post.tags = fm.tags; post.categories = fm.categories; post.created_at = fm.created_at; - post.updated_at = now; + post.updated_at = fm.updated_at; post.published_at = fm.published_at; qp::update_post(conn, &post)?; Ok(false) @@ -993,7 +991,7 @@ pub(crate) fn rebuild_canonical_post( published_categories: None, published_excerpt: None, created_at: fm.created_at, - updated_at: now, + updated_at: fm.updated_at, published_at: fm.published_at, }; qp::insert_post(conn, &post)?; @@ -1747,6 +1745,7 @@ mod tests { assert_eq!(post.title, "Rebuilt Post"); assert_eq!(post.slug, "rebuilt-post"); assert_eq!(post.tags, vec!["test"]); + assert_eq!(post.updated_at, 1_705_320_000_000); // Verify translation in DB let trans = diff --git a/crates/bds-core/src/engine/script_rebuild.rs b/crates/bds-core/src/engine/script_rebuild.rs index 4bd4e58..2f4c40e 100644 --- a/crates/bds-core/src/engine/script_rebuild.rs +++ b/crates/bds-core/src/engine/script_rebuild.rs @@ -8,7 +8,6 @@ use crate::db::queries::script as qs; use crate::engine::{EngineError, EngineResult}; use crate::model::{Script, ScriptStatus}; use crate::util::frontmatter::read_script_file; -use crate::util::now_unix_ms; /// Report returned by `rebuild_scripts_from_filesystem`. #[derive(Debug, Default)] @@ -83,8 +82,6 @@ pub(crate) fn rebuild_single_script( .to_string(); let kind = fm.kind.parse().map_err(EngineError::Parse)?; - let now = now_unix_ms(); - // File exists on disk -> Published; content is None in DB let status = ScriptStatus::Published; @@ -101,7 +98,7 @@ pub(crate) fn rebuild_single_script( script.status = status; script.content = None; script.created_at = fm.created_at; - script.updated_at = now; + script.updated_at = fm.updated_at; qs::update_script(conn, &script)?; Ok(false) } @@ -119,7 +116,7 @@ pub(crate) fn rebuild_single_script( status, content: None, created_at: fm.created_at, - updated_at: now, + updated_at: fm.updated_at, }; qs::insert_script(conn, &script)?; Ok(true) @@ -246,6 +243,7 @@ end assert!(script.enabled); assert_eq!(script.version, 5); assert_eq!(script.status, ScriptStatus::Published); + assert_eq!(script.updated_at, 1_704_067_200_000); assert!(script.content.is_none()); } diff --git a/crates/bds-core/src/engine/template_rebuild.rs b/crates/bds-core/src/engine/template_rebuild.rs index abc134d..3cf1ee0 100644 --- a/crates/bds-core/src/engine/template_rebuild.rs +++ b/crates/bds-core/src/engine/template_rebuild.rs @@ -8,7 +8,6 @@ use crate::db::queries::template as qt; use crate::engine::{EngineError, EngineResult}; use crate::model::{Template, TemplateStatus}; use crate::util::frontmatter::read_template_file; -use crate::util::now_unix_ms; /// Report returned by `rebuild_templates_from_filesystem`. #[derive(Debug, Default)] @@ -83,8 +82,6 @@ pub(crate) fn rebuild_single_template( .to_string(); let kind = fm.kind.parse().map_err(EngineError::Parse)?; - let now = now_unix_ms(); - // File exists on disk -> Published; content is None in DB let status = TemplateStatus::Published; @@ -100,7 +97,7 @@ pub(crate) fn rebuild_single_template( tpl.status = status; tpl.content = None; tpl.created_at = fm.created_at; - tpl.updated_at = now; + tpl.updated_at = fm.updated_at; qt::update_template(conn, &tpl)?; Ok(false) } @@ -117,7 +114,7 @@ pub(crate) fn rebuild_single_template( status, content: None, created_at: fm.created_at, - updated_at: now, + updated_at: fm.updated_at, }; qt::insert_template(conn, &tpl)?; Ok(true) @@ -236,6 +233,7 @@ updatedAt: \"2024-01-01T00:00:00.000Z\" assert_eq!(tpl.version, 3); assert_eq!(tpl.status, TemplateStatus::Published); assert!(tpl.content.is_none()); + assert_eq!(tpl.updated_at, 1_704_067_200_000); } #[test] diff --git a/crates/bds-core/tests/git_engine.rs b/crates/bds-core/tests/git_engine.rs new file mode 100644 index 0000000..434d592 --- /dev/null +++ b/crates/bds-core/tests/git_engine.rs @@ -0,0 +1,199 @@ +use std::fs; + +use bds_core::engine::git::{FileStatusKind, GitEngine}; + +fn git(dir: &std::path::Path, args: &[&str]) { + let output = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn git_text(dir: &std::path::Path, args: &[&str]) -> String { + let output = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +#[test] +fn status_diff_history_and_missing_diff_sides_follow_the_spec() { + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init", "-b", "master"]); + git(dir.path(), &["config", "user.name", "RuDS Test"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + fs::write(dir.path().join("kept.txt"), "before\n").unwrap(); + fs::write(dir.path().join("deleted.txt"), "gone\n").unwrap(); + git(dir.path(), &["add", "-A"]); + git(dir.path(), &["commit", "-m", "initial"]); + + fs::write(dir.path().join("kept.txt"), "after\n").unwrap(); + fs::remove_file(dir.path().join("deleted.txt")).unwrap(); + fs::write(dir.path().join("added.txt"), "new\n").unwrap(); + + let engine = GitEngine::new(dir.path()); + let status = engine.status().unwrap(); + assert!( + status + .iter() + .any(|file| { file.path == "kept.txt" && file.kind == FileStatusKind::Modified }) + ); + assert!( + status + .iter() + .any(|file| { file.path == "deleted.txt" && file.kind == FileStatusKind::Deleted }) + ); + assert!( + status + .iter() + .any(|file| { file.path == "added.txt" && file.kind == FileStatusKind::Untracked }) + ); + + let diff = engine.diff().unwrap(); + assert!(diff.unstaged.contains("-before")); + assert!(diff.unstaged.contains("+after")); + + let added = engine.file_diff("added.txt").unwrap(); + assert_eq!(added.original, ""); + assert_eq!(added.modified, "new\n"); + let deleted = engine.file_diff("deleted.txt").unwrap(); + assert_eq!(deleted.original, "gone\n"); + assert_eq!(deleted.modified, ""); + + assert_eq!(engine.file_history("kept.txt").unwrap().len(), 1); + + git(dir.path(), &["add", "-A"]); + git(dir.path(), &["commit", "-m", "working tree changes"]); + let hash = git_text(dir.path(), &["rev-parse", "HEAD"]); + let changes = engine.commit_files(&hash).unwrap(); + let added_change = changes + .iter() + .find(|change| change.path == "added.txt") + .unwrap(); + let deleted_change = changes + .iter() + .find(|change| change.path == "deleted.txt") + .unwrap(); + let added = engine.commit_file_diff(&hash, added_change).unwrap(); + let deleted = engine.commit_file_diff(&hash, deleted_change).unwrap(); + assert_eq!( + (added.original.as_str(), added.modified.as_str()), + ("", "new\n") + ); + assert_eq!( + (deleted.original.as_str(), deleted.modified.as_str()), + ("gone\n", "") + ); +} + +#[test] +fn commit_rejects_blank_messages_before_staging() { + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init", "-b", "master"]); + fs::write(dir.path().join("untracked.txt"), "content\n").unwrap(); + + let error = GitEngine::new(dir.path()).commit_all(" \n\t ").unwrap_err(); + assert!(error.to_string().contains("commit message")); + + let output = std::process::Command::new("git") + .args(["diff", "--cached", "--name-only"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.stdout.is_empty()); +} + +#[test] +fn remote_state_history_fetch_and_fast_forward_pull_use_real_refs() { + let root = tempfile::tempdir().unwrap(); + let remote = root.path().join("remote.git"); + let local = root.path().join("local"); + let peer = root.path().join("peer"); + fs::create_dir(&local).unwrap(); + git( + root.path(), + &["init", "--bare", "-b", "master", remote.to_str().unwrap()], + ); + git(&local, &["init", "-b", "master"]); + git(&local, &["config", "user.name", "Local"]); + git(&local, &["config", "user.email", "local@example.com"]); + fs::write(local.join("post.md"), "one\n").unwrap(); + git(&local, &["add", "-A"]); + git(&local, &["commit", "-m", "local one"]); + let engine = GitEngine::new(&local); + engine.set_remote(remote.to_str().unwrap()).unwrap(); + git(&local, &["push", "-u", "origin", "master"]); + + git( + root.path(), + &["clone", remote.to_str().unwrap(), peer.to_str().unwrap()], + ); + git(&peer, &["config", "user.name", "Peer"]); + git(&peer, &["config", "user.email", "peer@example.com"]); + fs::write(peer.join("post.md"), "two\n").unwrap(); + git(&peer, &["add", "-A"]); + git(&peer, &["commit", "-m", "remote two"]); + git(&peer, &["push"]); + + let mut streamed = String::new(); + engine + .fetch(|| false, |chunk| streamed.push_str(&chunk.text)) + .unwrap(); + let state = engine.remote_state().unwrap(); + assert_eq!(state.behind, 1); + assert_eq!(state.ahead, 0); + assert!( + engine + .history("master") + .unwrap() + .iter() + .any(|commit| commit.subject.as_deref() == Some("remote two") + && commit.sync_status == bds_core::engine::git::SyncStatus::RemoteOnly) + ); + + engine.pull(|| false, |_| {}).unwrap(); + let state = engine.remote_state().unwrap(); + assert_eq!((state.ahead, state.behind), (0, 0)); + assert_eq!(fs::read_to_string(local.join("post.md")).unwrap(), "two\n"); +} + +#[test] +fn file_history_follows_renames_and_is_limited_to_fifty_commits() { + let dir = tempfile::tempdir().unwrap(); + git(dir.path(), &["init", "-b", "master"]); + git(dir.path(), &["config", "user.name", "RuDS Test"]); + git(dir.path(), &["config", "user.email", "test@example.com"]); + fs::write(dir.path().join("old.txt"), "0\n").unwrap(); + git(dir.path(), &["add", "-A"]); + git(dir.path(), &["commit", "-m", "old name"]); + git(dir.path(), &["mv", "old.txt", "new.txt"]); + git(dir.path(), &["commit", "-m", "renamed"]); + for index in 1..=49 { + fs::write(dir.path().join("new.txt"), format!("{index}\n")).unwrap(); + git(dir.path(), &["add", "new.txt"]); + git(dir.path(), &["commit", "-m", &format!("change {index}")]); + } + + let history = GitEngine::new(dir.path()).file_history("new.txt").unwrap(); + + assert_eq!(history.len(), 50); + assert_eq!(history[0].subject.as_deref(), Some("change 49")); + assert!( + history + .iter() + .any(|commit| commit.subject.as_deref() == Some("renamed")) + ); +} diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 6e08228..8a2026a 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -31,6 +31,7 @@ use crate::views::{ DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardTimelineMonth, }, + git::{GitDiffLoad, GitDiffState, GitNetworkCompletion, GitSnapshot, GitUiState}, media_editor::{LinkedPostItem, MediaEditorMsg, MediaEditorState}, metadata_diff::MetadataDiffState, modal, @@ -47,6 +48,7 @@ use crate::views::{ mod editor_handlers; mod engine_handlers; +mod git_handlers; mod preview_handlers; mod search; mod tasks; @@ -213,6 +215,52 @@ pub enum Message { }, SiteValidationLoaded(Result), + // Git + GitRefresh, + GitLoaded { + repository_dir: PathBuf, + result: Result, + }, + GitRemoteInputChanged(String), + GitCommitMessageChanged(String), + GitInitialize, + GitSetRemote, + GitCommit, + GitFetch, + GitPull, + GitPush, + GitPruneLfs, + GitLocalFinished { + repository_dir: PathBuf, + operation: engine::git::GitOperation, + result: Result, + }, + GitNetworkFinished { + repository_dir: PathBuf, + task_id: TaskId, + operation: engine::git::GitOperation, + result: Result, + }, + OpenGitFileDiff(String), + OpenGitCommitDiff { + hash: String, + subject: String, + }, + SelectGitCommitFile { + hash: String, + change: engine::git::ChangedFile, + }, + GitDiffLoaded { + repository_dir: PathBuf, + tab_id: String, + result: Result, + }, + GitFileHistoryLoaded { + repository_dir: PathBuf, + path: String, + result: Result, String>, + }, + // Editor views PostEditor(PostEditorMsg), MediaEditor(MediaEditorMsg), @@ -823,6 +871,10 @@ pub struct BdsApp { site_validation_state: SiteValidationState, metadata_diff_state: MetadataDiffState, translation_validation_state: crate::views::translation_validation::TranslationValidationState, + git_state: GitUiState, + git_diffs: HashMap, + git_file_history: Vec, + git_file_history_target: Option, } // ─────────────────────────────────────────────────────────── @@ -962,6 +1014,10 @@ impl BdsApp { site_validation_state: SiteValidationState::default(), metadata_diff_state: MetadataDiffState::default(), translation_validation_state: Default::default(), + git_state: GitUiState::default(), + git_diffs: HashMap::new(), + git_file_history: Vec::new(), + git_file_history_target: None, }, init_task, ) @@ -1028,6 +1084,10 @@ impl BdsApp { site_validation_state: SiteValidationState::default(), metadata_diff_state: MetadataDiffState::default(), translation_validation_state: Default::default(), + git_state: GitUiState::default(), + git_diffs: HashMap::new(), + git_file_history: Vec::new(), + git_file_history_target: None, } } @@ -1053,6 +1113,8 @@ impl BdsApp { && matches!(new_view, SidebarView::Posts | SidebarView::Pages); if needs_post_refresh { self.refresh_sidebar_posts() + } else if new_view == SidebarView::Git && new_visible { + self.refresh_git() } else { Task::none() } @@ -1125,6 +1187,7 @@ impl BdsApp { } else { self.active_tab = None; } + self.git_diffs.remove(&id); self.enforce_panel_tab_fallback(); self.sync_menu_state(); self.sync_embedded_preview_for_active_post() @@ -1147,6 +1210,7 @@ impl BdsApp { self.flush_active_post_editor(); self.tabs.clear(); self.active_tab = None; + self.git_diffs.clear(); self.hide_embedded_preview(); Task::none() } @@ -1190,6 +1254,7 @@ impl BdsApp { if let Some(ref db) = self.db { match engine::project::set_active_project(db.conn(), &project_id) { Ok(()) => { + self.reset_git_for_project_change(); self.active_project = self.projects.iter().find(|p| p.id == project_id).cloned(); self.preview_session = None; @@ -1235,7 +1300,7 @@ impl BdsApp { } } self.sync_menu_state(); - Task::none() + self.refresh_git_if_visible() } Message::ProjectSwitched(result) => { match result { @@ -1266,6 +1331,7 @@ impl BdsApp { let _ = engine::project::set_active_project(db.conn(), &project.id); self.projects = engine::project::list_projects(db.conn()).unwrap_or_default(); + self.reset_git_for_project_change(); self.active_project = Some(project.clone()); self.preview_session = None; self.data_dir = project.data_path.as_ref().map(PathBuf::from); @@ -1284,7 +1350,7 @@ impl BdsApp { } } } - Task::none() + self.refresh_git_if_visible() } Message::ProjectCreated(result) => { match result { @@ -1354,6 +1420,7 @@ impl BdsApp { let _ = engine::project::set_active_project(db.conn(), &project.id); self.projects = engine::project::list_projects(db.conn()).unwrap_or_default(); + self.reset_git_for_project_change(); self.active_project = Some(project.clone()); self.data_dir = project.data_path.as_ref().map(PathBuf::from); self.preview_session = None; @@ -1367,7 +1434,10 @@ impl BdsApp { ), ); self.sync_menu_state(); - return self.refresh_counts(); + return Task::batch([ + self.refresh_counts(), + self.refresh_git_if_visible(), + ]); } Err(error) => self.notify( ToastLevel::Error, @@ -1817,7 +1887,11 @@ impl BdsApp { // ── Panel ── Message::SetPanelTab(tab) => { self.panel_tab = tab; - Task::none() + if tab == PanelTab::GitLog { + self.refresh_git_file_history() + } else { + Task::none() + } } // ── Settings ── @@ -1868,6 +1942,26 @@ impl BdsApp { | Message::SiteGenerationIndexDone { .. } | Message::SiteValidationLoaded(_)) => self.handle_engine_message(message), + // ── Git ── + message @ (Message::GitRefresh + | Message::GitLoaded { .. } + | Message::GitRemoteInputChanged(_) + | Message::GitCommitMessageChanged(_) + | Message::GitInitialize + | Message::GitSetRemote + | Message::GitCommit + | Message::GitFetch + | Message::GitPull + | Message::GitPush + | Message::GitPruneLfs + | Message::GitLocalFinished { .. } + | Message::GitNetworkFinished { .. } + | Message::OpenGitFileDiff(_) + | Message::OpenGitCommitDiff { .. } + | Message::SelectGitCommitFile { .. } + | Message::GitDiffLoaded { .. } + | Message::GitFileHistoryLoaded { .. }) => self.handle_git_message(message), + // ── Toasts ── Message::ShowToast(level, msg) => { self.toasts.push(Toast::new(level, msg)); @@ -2172,6 +2266,9 @@ impl BdsApp { &self.site_validation_state, &self.metadata_diff_state, &self.translation_validation_state, + &self.git_state, + &self.git_diffs, + &self.git_file_history, ) } @@ -6201,6 +6298,7 @@ mod tests { save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, }; + use crate::i18n::t; use crate::state::sidebar_filter::{MediaFilter, PostFilter}; use crate::views::media_editor::{MediaEditorMsg, MediaEditorState}; use crate::views::modal; @@ -6214,6 +6312,7 @@ mod tests { use bds_core::engine::generation::GenerationReport; use bds_core::engine::task::{TaskStatus, TaskStatus::*}; use bds_core::engine::{ai, media, post, script, template}; + use bds_core::i18n::UiLocale; use bds_core::model::{Project, ScriptKind, TemplateKind}; use chrono::{Datelike, TimeZone}; use std::io::{Read, Write}; @@ -6346,6 +6445,22 @@ mod tests { ); } + #[test] + fn git_network_actions_are_gated_in_airplane_mode() { + let (db, project, tempdir) = setup(); + let mut app = BdsApp::new_for_tests(db, project, tempdir.path().to_path_buf()); + app.offline_mode = true; + + let _ = app.update(Message::GitFetch); + + assert!(app.git_state.network_run.is_none()); + assert!( + app.toasts + .iter() + .any(|toast| toast.message == t(UiLocale::En, "git.airplaneBlocked")) + ); + } + #[test] fn gallery_completion_reports_every_path_inserts_macro_and_refreshes_editor() { let (db, project, tmp) = setup(); diff --git a/crates/bds-ui/src/app/git_handlers.rs b/crates/bds-ui/src/app/git_handlers.rs new file mode 100644 index 0000000..05580a3 --- /dev/null +++ b/crates/bds-ui/src/app/git_handlers.rs @@ -0,0 +1,633 @@ +use super::*; + +use bds_core::engine::git::{ + GitEngine, GitError, GitOperation, ReconcileAction, ReconcileEntityType, + reconcile_changed_files, +}; + +impl BdsApp { + pub(super) fn handle_git_message(&mut self, message: Message) -> Task { + match message { + Message::GitRefresh => self.refresh_git(), + Message::GitLoaded { + repository_dir, + result, + } => { + if self.data_dir.as_ref() != Some(&repository_dir) { + return Task::none(); + } + match result { + Ok(snapshot) => self.git_state.apply_snapshot(snapshot), + Err(error) => { + self.git_state.loading = false; + self.git_state.error = Some(error); + } + } + Task::none() + } + Message::GitRemoteInputChanged(value) => { + self.git_state.remote_input = value; + Task::none() + } + Message::GitCommitMessageChanged(value) => { + self.git_state.commit_message = value; + Task::none() + } + Message::GitInitialize => { + let remote = self.git_state.remote_input.trim().to_string(); + self.run_local_git_action(GitOperation::Initialize, move |engine| { + engine.initialize()?; + if !remote.is_empty() { + engine.set_remote(&remote)?; + } + Ok(()) + }) + } + Message::GitSetRemote => { + let remote = self.git_state.remote_input.trim().to_string(); + self.run_local_git_action(GitOperation::Remote, move |engine| { + engine.set_remote(&remote) + }) + } + Message::GitCommit => { + let message = self.git_state.commit_message.clone(); + self.run_local_git_action(GitOperation::Commit, move |engine| { + engine.commit_all(&message).map(|_| ()) + }) + } + Message::GitPruneLfs => self.run_local_git_action(GitOperation::Lfs, |engine| { + engine.prune_lfs_cache(10).map(|_| ()) + }), + Message::GitLocalFinished { + repository_dir, + operation, + result, + } => { + if self.data_dir.as_ref() != Some(&repository_dir) { + return Task::none(); + } + match result { + Ok(snapshot) => { + self.git_state.apply_snapshot(snapshot); + if operation == GitOperation::Commit { + self.git_state.commit_message.clear(); + self.close_git_diff_tabs(); + } + self.notify( + ToastLevel::Success, + &tw( + self.ui_locale, + "git.operationDone", + &[("operation", &git_operation_label(self.ui_locale, operation))], + ), + ); + } + Err(error) => { + self.git_state.loading = false; + self.git_state.error = Some(error.clone()); + self.notify(ToastLevel::Error, &error); + } + } + Task::none() + } + Message::GitFetch => self.start_git_network(GitOperation::Fetch), + Message::GitPull => self.start_git_network(GitOperation::Pull), + Message::GitPush => self.start_git_network(GitOperation::Push), + Message::GitNetworkFinished { + repository_dir, + task_id, + operation, + result, + } => { + if self.data_dir.as_ref() != Some(&repository_dir) { + self.refresh_task_snapshots(); + return Task::none(); + } + self.git_state.network_run = None; + if self.task_manager.status(task_id) == Some(TaskStatus::Cancelled) { + self.refresh_task_snapshots(); + return Task::none(); + } + match result { + Ok(completion) => { + self.task_manager.complete(task_id); + if !completion.output.trim().is_empty() { + self.add_output(completion.output.trim()); + } + self.git_state.apply_snapshot(completion.snapshot); + self.apply_git_reconcile_events(&completion.events); + self.notify( + ToastLevel::Success, + &tw( + self.ui_locale, + "git.operationDone", + &[("operation", &git_operation_label(self.ui_locale, operation))], + ), + ); + self.refresh_task_snapshots(); + return self.refresh_counts(); + } + Err(error) => { + if self.task_manager.status(task_id) != Some(TaskStatus::Cancelled) { + self.task_manager.fail(task_id, error.clone()); + self.notify(ToastLevel::Error, &error); + } + self.git_state.error = Some(error); + } + } + self.refresh_task_snapshots(); + Task::none() + } + Message::OpenGitFileDiff(path) => self.open_git_file_diff(path), + Message::OpenGitCommitDiff { hash, subject } => { + self.open_git_commit_diff(hash, subject) + } + Message::SelectGitCommitFile { hash, change } => { + self.load_git_commit_file(hash, change) + } + Message::GitDiffLoaded { + repository_dir, + tab_id, + result, + } => { + if self.data_dir.as_ref() != Some(&repository_dir) { + return Task::none(); + } + if let Some(state) = self.git_diffs.get_mut(&tab_id) { + state.loading = false; + match result { + Ok(loaded) => { + state.changes = loaded.changes; + state.selected_path = loaded.selected_path; + state.diff = loaded.diff; + state.error = None; + } + Err(error) => state.error = Some(error), + } + } + Task::none() + } + Message::GitFileHistoryLoaded { + repository_dir, + path, + result, + } => { + if self.data_dir.as_ref() != Some(&repository_dir) { + return Task::none(); + } + if self.git_file_history_target.as_deref() == Some(&path) { + match result { + Ok(commits) => self.git_file_history = commits, + Err(error) => { + self.git_file_history.clear(); + self.add_output(&error); + } + } + } + Task::none() + } + _ => Task::none(), + } + } + + pub(super) fn refresh_git(&mut self) -> Task { + let Some(data_dir) = self.data_dir.clone() else { + self.git_state = GitUiState::default(); + return Task::none(); + }; + self.git_state.loading = true; + let repository_dir = data_dir.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || git_snapshot(&GitEngine::new(data_dir))) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + }, + move |result| Message::GitLoaded { + repository_dir: repository_dir.clone(), + result, + }, + ) + } + + fn run_local_git_action(&mut self, operation: GitOperation, work: F) -> Task + where + F: FnOnce(&GitEngine) -> Result<(), GitError> + Send + 'static, + { + let Some(data_dir) = self.data_dir.clone() else { + return Task::none(); + }; + self.git_state.loading = true; + self.git_state.error = None; + let repository_dir = data_dir.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let engine = GitEngine::new(data_dir); + work(&engine).map_err(|error| error.to_string())?; + git_snapshot(&engine) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + }, + move |result| Message::GitLocalFinished { + repository_dir: repository_dir.clone(), + operation, + result, + }, + ) + } + + fn start_git_network(&mut self, operation: GitOperation) -> Task { + if self.offline_mode { + self.notify( + ToastLevel::Warning, + &t(self.ui_locale, "git.airplaneBlocked"), + ); + return Task::none(); + } + if self.git_state.network_run.is_some() { + return Task::none(); + } + let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else { + return Task::none(); + }; + let project_id = project.id.clone(); + let data_dir = data_dir.clone(); + let repository_dir = data_dir.clone(); + let db_path = self.db_path.clone(); + let operation_label = git_operation_label(self.ui_locale, operation); + let label = tw( + self.ui_locale, + "git.runningOperation", + &[("operation", &operation_label)], + ); + let task_id = self.task_manager.submit(&label); + let output = Arc::new(Mutex::new(Vec::new())); + self.git_state.network_run = Some(crate::views::git::GitNetworkRunState { + task_id, + output: Arc::clone(&output), + }); + self.git_state.error = None; + self.refresh_task_snapshots(); + let task_manager = Arc::clone(&self.task_manager); + + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + if !task_manager.wait_until_runnable(task_id) { + return Err("cancelled".to_string()); + } + let engine = GitEngine::new(&data_dir); + let old_head = (operation == GitOperation::Pull) + .then(|| engine.head()) + .transpose() + .map_err(|error| error.to_string())? + .flatten(); + let output_target = Arc::clone(&output); + let result = match operation { + GitOperation::Fetch => engine.fetch( + || task_manager.is_cancelled(task_id), + move |chunk| output_target.lock().unwrap().push(chunk), + ), + GitOperation::Pull => engine.pull( + || task_manager.is_cancelled(task_id), + move |chunk| output_target.lock().unwrap().push(chunk), + ), + GitOperation::Push => engine.push( + || task_manager.is_cancelled(task_id), + move |chunk| output_target.lock().unwrap().push(chunk), + ), + _ => unreachable!("only network operations are queued"), + } + .map_err(|error| error.to_string())?; + + let mut events = Vec::new(); + if operation == GitOperation::Pull + && let (Some(old_head), Some(new_head)) = + (old_head, engine.head().map_err(|error| error.to_string())?) + && old_head != new_head + { + let changes = engine + .changed_files(&old_head, &new_head) + .map_err(|error| error.to_string())?; + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let report = reconcile_changed_files( + db.conn(), + &data_dir, + &project_id, + &changes, + |event| events.push(event.clone()), + ) + .map_err(|error| error.to_string())?; + debug_assert_eq!(events, report.events); + } + Ok(GitNetworkCompletion { + snapshot: git_snapshot(&engine)?, + events, + output: result.output, + }) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + }, + move |result| Message::GitNetworkFinished { + repository_dir: repository_dir.clone(), + task_id, + operation, + result, + }, + ) + } + + fn open_git_file_diff(&mut self, path: String) -> Task { + let tab = crate::views::git::file_tab(&path); + let tab_id = tab.id.clone(); + self.activate_git_tab(tab); + self.git_diffs + .insert(tab_id.clone(), GitDiffState::loading_file(path.clone())); + let Some(data_dir) = self.data_dir.clone() else { + return Task::none(); + }; + let repository_dir = data_dir.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let diff = GitEngine::new(data_dir) + .file_diff(&path) + .map_err(|error| error.to_string())?; + Ok(GitDiffLoad { + changes: Vec::new(), + selected_path: Some(path), + diff: Some(diff), + }) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + }, + move |result| Message::GitDiffLoaded { + repository_dir: repository_dir.clone(), + tab_id: tab_id.clone(), + result, + }, + ) + } + + fn open_git_commit_diff(&mut self, hash: String, subject: String) -> Task { + let tab = crate::views::git::commit_tab(&hash, &subject); + let tab_id = tab.id.clone(); + self.activate_git_tab(tab); + self.git_diffs + .insert(tab_id.clone(), GitDiffState::loading_commit(hash.clone())); + let Some(data_dir) = self.data_dir.clone() else { + return Task::none(); + }; + let repository_dir = data_dir.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let engine = GitEngine::new(data_dir); + let changes = engine + .commit_files(&hash) + .map_err(|error| error.to_string())?; + let selected = changes.first().cloned(); + let diff = selected + .as_ref() + .map(|change| engine.commit_file_diff(&hash, change)) + .transpose() + .map_err(|error| error.to_string())?; + Ok(GitDiffLoad { + selected_path: selected.map(|change| change.path), + changes, + diff, + }) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + }, + move |result| Message::GitDiffLoaded { + repository_dir: repository_dir.clone(), + tab_id: tab_id.clone(), + result, + }, + ) + } + + fn load_git_commit_file( + &mut self, + hash: String, + change: engine::git::ChangedFile, + ) -> Task { + let tab_id = format!("git-diff:commit:{hash}"); + if let Some(state) = self.git_diffs.get_mut(&tab_id) { + state.loading = true; + state.selected_path = Some(change.path.clone()); + state.error = None; + } + let Some(data_dir) = self.data_dir.clone() else { + return Task::none(); + }; + let repository_dir = data_dir.clone(); + let changes = self + .git_diffs + .get(&tab_id) + .map(|state| state.changes.clone()) + .unwrap_or_default(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + let diff = GitEngine::new(data_dir) + .commit_file_diff(&hash, &change) + .map_err(|error| error.to_string())?; + Ok(GitDiffLoad { + changes, + selected_path: Some(change.path), + diff: Some(diff), + }) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))) + }, + move |result| Message::GitDiffLoaded { + repository_dir: repository_dir.clone(), + tab_id: tab_id.clone(), + result, + }, + ) + } + + fn activate_git_tab(&mut self, tab: Tab) { + self.flush_active_post_editor(); + let index = tabs::open_tab(&mut self.tabs, tab); + self.active_tab = self.tabs.get(index).map(|tab| tab.id.clone()); + self.enforce_panel_tab_fallback(); + self.sync_menu_state(); + } + + fn close_git_diff_tabs(&mut self) { + let ids = self + .tabs + .iter() + .filter(|tab| tab.tab_type == TabType::GitDiff) + .map(|tab| tab.id.clone()) + .collect::>(); + for id in ids { + self.git_diffs.remove(&id); + if let Some(index) = tabs::close_tab(&mut self.tabs, &id) { + self.active_tab = self.tabs.get(index).map(|tab| tab.id.clone()); + } else { + self.active_tab = None; + } + } + } + + pub(super) fn refresh_git_file_history(&mut self) -> Task { + let Some(path) = self.active_git_history_path() else { + self.git_file_history.clear(); + self.git_file_history_target = None; + return Task::none(); + }; + let Some(data_dir) = self.data_dir.clone() else { + return Task::none(); + }; + let repository_dir = data_dir.clone(); + self.git_file_history_target = Some(path.clone()); + self.git_file_history.clear(); + Task::perform( + async move { + let task_path = path.clone(); + let result = tokio::task::spawn_blocking(move || { + GitEngine::new(data_dir) + .file_history(&task_path) + .map_err(|error| error.to_string()) + }) + .await + .unwrap_or_else(|error| Err(format!("task panicked: {error}"))); + (path, result) + }, + move |(path, result)| Message::GitFileHistoryLoaded { + repository_dir: repository_dir.clone(), + path, + result, + }, + ) + } + + fn active_git_history_path(&self) -> Option { + let tab_id = self.active_tab.as_deref()?; + let tab = self.tabs.iter().find(|tab| tab.id == tab_id)?; + match tab.tab_type { + TabType::Post => self + .db + .as_ref() + .and_then(|db| bds_core::db::queries::post::get_post_by_id(db.conn(), tab_id).ok()) + .map(|post| post.file_path) + .filter(|path| !path.is_empty()), + TabType::Media => self + .media_editors + .get(tab_id) + .map(|media| media.file_path.clone()) + .filter(|path| !path.is_empty()), + _ => None, + } + } + + fn apply_git_reconcile_events(&mut self, events: &[engine::git::ReconcileEvent]) { + for event in events { + match event.entity_type { + ReconcileEntityType::Post => { + self.post_editors.remove(&event.entity_id); + } + ReconcileEntityType::PostTranslation => self.post_editors.clear(), + ReconcileEntityType::Script => { + self.script_editors.remove(&event.entity_id); + } + ReconcileEntityType::Template => { + self.template_editors.remove(&event.entity_id); + } + } + if event.action == ReconcileAction::Deleted { + let was_active = self.active_tab.as_deref() == Some(&event.entity_id); + match tabs::close_tab(&mut self.tabs, &event.entity_id) { + Some(index) if was_active => { + self.active_tab = self.tabs.get(index).map(|tab| tab.id.clone()); + } + None if was_active => self.active_tab = None, + _ => {} + } + } + } + self.enforce_panel_tab_fallback(); + self.sync_menu_state(); + } + + pub(super) fn reset_git_for_project_change(&mut self) { + if let Some(run) = self.git_state.network_run.take() { + self.task_manager.cancel(run.task_id); + } + self.close_git_diff_tabs(); + self.git_state = GitUiState::default(); + self.git_file_history.clear(); + self.git_file_history_target = None; + self.refresh_task_snapshots(); + } + + pub(super) fn refresh_git_if_visible(&mut self) -> Task { + if self.sidebar_visible && self.sidebar_view == SidebarView::Git { + self.refresh_git() + } else { + Task::none() + } + } +} + +fn git_operation_label(locale: bds_core::i18n::UiLocale, operation: GitOperation) -> String { + t( + locale, + match operation { + GitOperation::Initialize => "git.initialize", + GitOperation::Status => "git.noChanges", + GitOperation::Diff => "git.diff", + GitOperation::History => "git.history", + GitOperation::Remote => "git.remoteUrl", + GitOperation::Fetch => "git.fetch", + GitOperation::Pull | GitOperation::Reconcile => "git.pull", + GitOperation::Push => "git.push", + GitOperation::Commit => "git.commit", + GitOperation::Lfs => "git.pruneLfs", + }, + ) +} + +fn git_snapshot(engine: &GitEngine) -> Result { + let repository = engine.repository().map_err(|error| error.to_string())?; + if !repository.is_initialized { + return Ok(GitSnapshot { + repository, + files: Vec::new(), + history: Vec::new(), + remote: bds_core::engine::git::GitRemoteState { + local_branch: None, + upstream_branch: None, + has_upstream: false, + ahead: 0, + behind: 0, + }, + }); + } + let files = engine.status().map_err(|error| error.to_string())?; + let remote = engine.remote_state().map_err(|error| error.to_string())?; + let history = repository + .current_branch + .as_deref() + .map(|branch| engine.history(branch)) + .transpose() + .map_err(|error| error.to_string())? + .unwrap_or_default(); + Ok(GitSnapshot { + repository, + files, + history, + remote, + }) +} diff --git a/crates/bds-ui/src/views/git.rs b/crates/bds-ui/src/views/git.rs new file mode 100644 index 0000000..ff3170c --- /dev/null +++ b/crates/bds-ui/src/views/git.rs @@ -0,0 +1,525 @@ +use std::sync::{Arc, Mutex}; + +use bds_core::engine::git::{ + ChangedFile, GitCommit, GitFileDiff, GitFileStatus, GitOutput, GitRemoteState, GitRepository, + ReconcileEvent, SyncStatus, +}; +use bds_core::i18n::UiLocale; +use iced::widget::text::{Shaping, Wrapping}; +use iced::widget::{Space, button, column, container, row, scrollable, text, text_input}; +use iced::{Color, Element, Font, Length}; + +use crate::app::Message; +use crate::components::inputs; +use crate::i18n::{t, tw}; +use crate::state::tabs::{Tab, TabType}; + +#[derive(Debug, Clone)] +pub struct GitSnapshot { + pub repository: GitRepository, + pub files: Vec, + pub history: Vec, + pub remote: GitRemoteState, +} + +#[derive(Debug, Clone)] +pub struct GitNetworkCompletion { + pub snapshot: GitSnapshot, + pub events: Vec, + pub output: String, +} + +#[derive(Debug, Clone)] +pub struct GitUiState { + pub repository: GitRepository, + pub files: Vec, + pub history: Vec, + pub remote: GitRemoteState, + pub remote_input: String, + pub commit_message: String, + pub loading: bool, + pub error: Option, + pub network_run: Option, +} + +impl Default for GitUiState { + fn default() -> Self { + Self { + repository: GitRepository { + is_initialized: false, + remote_url: None, + provider: None, + current_branch: None, + has_lfs: false, + }, + files: Vec::new(), + history: Vec::new(), + remote: GitRemoteState { + local_branch: None, + upstream_branch: None, + has_upstream: false, + ahead: 0, + behind: 0, + }, + remote_input: String::new(), + commit_message: String::new(), + loading: false, + error: None, + network_run: None, + } + } +} + +impl GitUiState { + pub fn apply_snapshot(&mut self, snapshot: GitSnapshot) { + self.remote_input = snapshot.repository.remote_url.clone().unwrap_or_default(); + self.repository = snapshot.repository; + self.files = snapshot.files; + self.history = snapshot.history; + self.remote = snapshot.remote; + self.loading = false; + self.error = None; + } +} + +#[derive(Debug, Clone)] +pub struct GitNetworkRunState { + pub task_id: u64, + pub output: Arc>>, +} + +#[derive(Debug, Clone)] +pub enum GitDiffKind { + File, + Commit { hash: String }, +} + +#[derive(Debug, Clone)] +pub struct GitDiffState { + pub kind: GitDiffKind, + pub changes: Vec, + pub selected_path: Option, + pub diff: Option, + pub loading: bool, + pub error: Option, +} + +#[derive(Debug, Clone)] +pub struct GitDiffLoad { + pub changes: Vec, + pub selected_path: Option, + pub diff: Option, +} + +impl GitDiffState { + pub fn loading_file(path: String) -> Self { + Self { + kind: GitDiffKind::File, + changes: Vec::new(), + selected_path: Some(path), + diff: None, + loading: true, + error: None, + } + } + + pub fn loading_commit(hash: String) -> Self { + Self { + kind: GitDiffKind::Commit { hash }, + changes: Vec::new(), + selected_path: None, + diff: None, + loading: true, + error: None, + } + } +} + +pub fn sidebar_view( + state: &GitUiState, + offline_mode: bool, + locale: UiLocale, +) -> Element<'static, Message> { + if state.loading && !state.repository.is_initialized { + return text(t(locale, "git.loading")) + .size(12) + .color(Color::from_rgb(0.5, 0.5, 0.55)) + .into(); + } + if !state.repository.is_initialized { + return column![ + text(t(locale, "git.notRepository")) + .size(12) + .color(Color::from_rgb(0.6, 0.6, 0.65)), + text_input(&t(locale, "git.remoteOptional"), &state.remote_input) + .on_input(Message::GitRemoteInputChanged) + .size(12) + .padding([6, 8]) + .style(inputs::field_style), + button(text(t(locale, "git.initialize")).size(12)) + .on_press(Message::GitInitialize) + .padding([5, 8]) + .style(inputs::primary_button), + error_view(state.error.as_deref()), + ] + .spacing(8) + .into(); + } + + let branch = state.repository.current_branch.as_deref().unwrap_or("—"); + let upstream = state.remote.upstream_branch.as_deref().unwrap_or("—"); + let header = column![ + text(format!("⎇ {branch}")) + .size(13) + .shaping(Shaping::Advanced), + text(format!( + "{upstream} ↑{} ↓{}", + state.remote.ahead, state.remote.behind + )) + .size(11) + .color(Color::from_rgb(0.55, 0.55, 0.6)), + ] + .spacing(2); + + let network_running = state.network_run.is_some(); + let network_button = |key, message| { + let button = button(text(t(locale, key)).size(11)) + .padding([4, 6]) + .style(inputs::secondary_button); + if offline_mode || network_running { + button + } else { + button.on_press(message) + } + }; + let actions = row![ + network_button("git.fetch", Message::GitFetch), + network_button("git.pull", Message::GitPull), + network_button("git.push", Message::GitPush), + ] + .spacing(4) + .wrap(); + + let mut content: Vec> = vec![header.into(), actions.into()]; + if offline_mode { + content.push( + text(t(locale, "git.airplaneBlocked")) + .size(10) + .color(Color::from_rgb(0.72, 0.58, 0.35)) + .into(), + ); + } + content.push( + row![ + text_input(&t(locale, "git.remoteUrl"), &state.remote_input) + .on_input(Message::GitRemoteInputChanged) + .size(11) + .padding([5, 7]) + .style(inputs::field_style), + button(text(t(locale, "git.saveRemote")).size(11)) + .on_press(Message::GitSetRemote) + .padding([5, 7]) + .style(inputs::secondary_button), + ] + .spacing(4) + .into(), + ); + content.push(section_title( + tw( + locale, + "git.changesCount", + &[("count", &state.files.len().to_string())], + ), + locale, + )); + content.push( + row![ + text_input(&t(locale, "git.commitMessage"), &state.commit_message) + .on_input(Message::GitCommitMessageChanged) + .size(11) + .padding([5, 7]) + .style(inputs::field_style), + { + let commit = button(text(t(locale, "git.commit")).size(11)) + .padding([5, 7]) + .style(inputs::primary_button); + if state.files.is_empty() || state.commit_message.trim().is_empty() { + commit + } else { + commit.on_press(Message::GitCommit) + } + }, + ] + .spacing(4) + .into(), + ); + if state.files.is_empty() { + content.push(muted_text(t(locale, "git.noChanges"))); + } else { + content.extend(state.files.iter().map(status_button)); + } + content.push(section_title(t(locale, "git.history"), locale)); + if state.history.is_empty() { + content.push(muted_text(t(locale, "git.noCommits"))); + } else { + content.extend(state.history.iter().take(20).map(history_button)); + } + content.push( + button(text(t(locale, "git.pruneLfs")).size(11)) + .on_press(Message::GitPruneLfs) + .padding([4, 7]) + .style(inputs::secondary_button) + .into(), + ); + if let Some(run) = &state.network_run { + content.push(network_output(run, locale)); + } + content.push(error_view(state.error.as_deref())); + iced::widget::Column::with_children(content) + .spacing(6) + .into() +} + +fn section_title(label: String, _locale: UiLocale) -> Element<'static, Message> { + text(label) + .size(11) + .color(Color::from_rgb(0.62, 0.62, 0.68)) + .into() +} + +fn muted_text(label: String) -> Element<'static, Message> { + text(label) + .size(11) + .color(Color::from_rgb(0.5, 0.5, 0.55)) + .into() +} + +fn error_view(error: Option<&str>) -> Element<'static, Message> { + text(error.unwrap_or_default().to_string()) + .size(11) + .color(Color::from_rgb(0.85, 0.35, 0.35)) + .into() +} + +fn status_button(file: &GitFileStatus) -> Element<'static, Message> { + let path = file.path.clone(); + button( + row![ + text(path.clone()).size(11), + Space::with_width(Length::Fill), + text(file.kind.code()).size(11), + ] + .spacing(6), + ) + .on_press(Message::OpenGitFileDiff(path)) + .width(Length::Fill) + .padding([5, 8]) + .style(inputs::disclosure_button) + .into() +} + +fn history_button(commit: &GitCommit) -> Element<'static, Message> { + let hash = commit.hash.clone(); + let subject = commit.subject.clone().unwrap_or_else(|| hash.clone()); + let short = hash.chars().take(7).collect::(); + let marker = match commit.sync_status { + SyncStatus::Both => "●", + SyncStatus::LocalOnly => "↑", + SyncStatus::RemoteOnly => "↓", + }; + button( + column![ + text(subject.clone()).size(11), + text(format!("{marker} {short}")) + .size(10) + .color(Color::from_rgb(0.5, 0.5, 0.56)), + ] + .spacing(1), + ) + .on_press(Message::OpenGitCommitDiff { hash, subject }) + .width(Length::Fill) + .padding([5, 8]) + .style(inputs::disclosure_button) + .into() +} + +fn network_output(run: &GitNetworkRunState, locale: UiLocale) -> Element<'static, Message> { + let output = run + .output + .lock() + .unwrap() + .iter() + .map(|chunk| chunk.text.as_str()) + .collect::(); + column![ + text(t(locale, "git.liveOutput")) + .size(11) + .color(Color::from_rgb(0.62, 0.62, 0.68)), + container( + text(output) + .size(10) + .font(Font::MONOSPACE) + .wrapping(Wrapping::Word) + ) + .padding(6), + button(text(t(locale, "common.cancel")).size(11)) + .on_press(Message::CancelTask(run.task_id)) + .padding([4, 7]) + .style(inputs::secondary_button), + ] + .spacing(4) + .into() +} + +pub fn diff_view( + state: &GitDiffState, + view_style: &str, + wrap_long_lines: bool, + locale: UiLocale, +) -> Element<'static, Message> { + let title = match &state.kind { + GitDiffKind::File => state + .selected_path + .clone() + .unwrap_or_else(|| t(locale, "git.diff")), + GitDiffKind::Commit { hash } => tw( + locale, + "git.commitDiffTitle", + &[("hash", &hash.chars().take(7).collect::())], + ), + }; + let header = inputs::card( + column![ + text(title).size(20).shaping(Shaping::Advanced), + text(t(locale, "git.readOnly")) + .size(11) + .color(Color::from_rgb(0.58, 0.58, 0.63)), + ] + .spacing(4), + ); + let mut sections: Vec> = vec![header.into()]; + if matches!(state.kind, GitDiffKind::Commit { .. }) && !state.changes.is_empty() { + sections.push(inputs::toolbar( + state + .changes + .iter() + .map(|change| { + let selected = state.selected_path.as_deref() == Some(&change.path); + let button = button(text(change.path.clone()).size(11)) + .padding([4, 7]) + .style(if selected { + inputs::primary_button + } else { + inputs::secondary_button + }); + if selected { + button.into() + } else if let GitDiffKind::Commit { hash } = &state.kind { + button + .on_press(Message::SelectGitCommitFile { + hash: hash.clone(), + change: change.clone(), + }) + .into() + } else { + button.into() + } + }) + .collect(), + Vec::new(), + )); + } + if state.loading { + sections.push(muted_text(t(locale, "git.loadingDiff"))); + } else if let Some(error) = &state.error { + sections.push(error_view(Some(error))); + } else if let Some(diff) = &state.diff { + let wrapping = if wrap_long_lines { + Wrapping::Word + } else { + Wrapping::None + }; + if view_style == "side-by-side" { + let original = code_card(t(locale, "git.original"), diff.original.clone(), wrapping); + let modified = code_card(t(locale, "git.modified"), diff.modified.clone(), wrapping); + sections.push( + row![original, modified] + .spacing(12) + .height(Length::Fill) + .into(), + ); + } else { + sections.push(code_card( + t(locale, "git.inlineDiff"), + diff.patch.clone(), + wrapping, + )); + } + } else { + sections.push(muted_text(t(locale, "git.noDiff"))); + } + container(scrollable( + iced::widget::Column::with_children(sections) + .spacing(12) + .padding(16), + )) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn code_card(label: String, contents: String, wrapping: Wrapping) -> Element<'static, Message> { + inputs::card( + column![ + text(label) + .size(12) + .color(Color::from_rgb(0.66, 0.66, 0.72)), + scrollable( + text(contents) + .size(12) + .font(Font::MONOSPACE) + .wrapping(wrapping) + ) + .height(Length::Fill), + ] + .spacing(8) + .height(Length::Fill), + ) + .height(Length::Fill) + .into() +} + +pub fn file_tab(path: &str) -> Tab { + Tab { + id: format!("git-diff:{path}"), + tab_type: TabType::GitDiff, + title: PathTitle(path).to_string(), + is_transient: true, + is_dirty: false, + } +} + +pub fn commit_tab(hash: &str, subject: &str) -> Tab { + let short = hash.chars().take(7).collect::(); + Tab { + id: format!("git-diff:commit:{hash}"), + tab_type: TabType::GitDiff, + title: if subject.is_empty() { + short + } else { + format!("{short} {subject}") + }, + is_transient: true, + is_dirty: false, + } +} + +struct PathTitle<'a>(&'a str); + +impl fmt::Display for PathTitle<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0.rsplit('/').next().unwrap_or(self.0)) + } +} + +use std::fmt; diff --git a/crates/bds-ui/src/views/mod.rs b/crates/bds-ui/src/views/mod.rs index e401ec0..1543117 100644 --- a/crates/bds-ui/src/views/mod.rs +++ b/crates/bds-ui/src/views/mod.rs @@ -1,5 +1,6 @@ pub mod activity_bar; pub mod dashboard; +pub mod git; pub mod media_editor; pub mod metadata_diff; pub mod modal; diff --git a/crates/bds-ui/src/views/panel.rs b/crates/bds-ui/src/views/panel.rs index 4ffe652..9c13eb8 100644 --- a/crates/bds-ui/src/views/panel.rs +++ b/crates/bds-ui/src/views/panel.rs @@ -2,6 +2,7 @@ use iced::widget::text::Shaping; use iced::widget::{Space, button, column, container, row, scrollable, text}; use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; +use bds_core::engine::git::GitCommit; use bds_core::i18n::UiLocale; use crate::app::Message; @@ -109,6 +110,7 @@ pub fn view( locale: UiLocale, active_tab_is_post: bool, active_tab_is_post_or_media: bool, + git_file_history: &[GitCommit], ) -> Element<'static, Message> { let muted = Color::from_rgb(0.50, 0.50, 0.55); @@ -349,15 +351,45 @@ pub fn view( } } PanelTab::GitLog => { - // Git Log content populated in extension bucket A (git integration) - container( - text(t(locale, "panel.gitLogPlaceholder")) - .size(12) - .shaping(Shaping::Advanced) - .color(muted), - ) - .padding(8) - .into() + if git_file_history.is_empty() { + container( + text(t(locale, "git.noFileHistory")) + .size(12) + .shaping(Shaping::Advanced) + .color(muted), + ) + .padding(8) + .into() + } else { + let items = git_file_history + .iter() + .map(|commit| { + let hash = commit.hash.clone(); + let subject = commit.subject.clone().unwrap_or_else(|| hash.clone()); + let short = hash.chars().take(7).collect::(); + button( + row![ + text(short).size(11).font(iced::Font::MONOSPACE), + text(subject.clone()).size(11), + Space::with_width(Length::Fill), + text(commit.date.clone().unwrap_or_default()).size(10), + ] + .spacing(8), + ) + .on_press(Message::OpenGitCommitDiff { hash, subject }) + .padding([4, 8]) + .width(Length::Fill) + .style(inputs::disclosure_button) + .into() + }) + .collect::>>(); + scrollable( + iced::widget::Column::with_children(items) + .spacing(2) + .padding(8), + ) + .into() + } } }; diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs index 87da183..1b9b278 100644 --- a/crates/bds-ui/src/views/sidebar.rs +++ b/crates/bds-ui/src/views/sidebar.rs @@ -13,6 +13,7 @@ use crate::i18n::t; use crate::state::navigation::SidebarView; use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter}; use crate::state::tabs::{Tab, TabType}; +use crate::views::git::GitUiState; /// Sidebar container style — dark background. fn sidebar_style(_theme: &Theme) -> container::Style { @@ -79,7 +80,7 @@ fn placeholder_key(view: SidebarView) -> &'static str { SidebarView::Tags => "sidebar.tagsHeader", SidebarView::Chat => "sidebar.chatPlaceholder", SidebarView::Import => "sidebar.importPlaceholder", - SidebarView::Git => "sidebar.gitPlaceholder", + SidebarView::Git => "git.noChanges", SidebarView::Settings => "sidebar.settingsHeader", } } @@ -635,6 +636,8 @@ pub fn view( active_tab: Option<&str>, locale: UiLocale, _data_dir: Option<&Path>, + git_state: &GitUiState, + offline_mode: bool, ) -> Element<'static, Message> { let header_text = t(locale, sidebar_view.i18n_key()); let muted = Color::from_rgb(0.50, 0.50, 0.55); @@ -1140,6 +1143,7 @@ pub fn view( .collect(); iced::widget::Column::with_children(items).spacing(1).into() } + SidebarView::Git => crate::views::git::sidebar_view(git_state, offline_mode, locale), _ => text(t(locale, placeholder_key(sidebar_view))) .size(12) .shaping(Shaping::Advanced) diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 627e24f..89e9757 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -5,6 +5,7 @@ use iced::widget::text::Shaping; use iced::widget::{Space, button, column, container, mouse_area, row, stack, text}; use iced::{Alignment, Background, Color, Element, Length, Padding, Theme}; +use bds_core::engine::git::GitCommit; use bds_core::i18n::UiLocale; use bds_core::model::{Media, Post, Project, Script, Template}; @@ -16,6 +17,7 @@ use crate::state::toast::Toast; use crate::views::{ activity_bar, dashboard::DashboardState, + git::{self, GitDiffState, GitUiState}, media_editor::{self, MediaEditorState}, metadata_diff::{self, MetadataDiffState}, modal, panel, @@ -123,6 +125,9 @@ pub fn view<'a>( site_validation_state: &'a SiteValidationState, metadata_diff_state: &'a MetadataDiffState, translation_validation_state: &'a TranslationValidationState, + git_state: &'a GitUiState, + git_diffs: &'a HashMap, + git_file_history: &'a [GitCommit], ) -> Element<'a, Message> { // Activity bar (leftmost column) let activity = activity_bar::view(sidebar_view, sidebar_visible, locale); @@ -148,6 +153,7 @@ pub fn view<'a>( site_validation_state, metadata_diff_state, translation_validation_state, + git_diffs, ); // Right column: tab bar + content + panel @@ -176,6 +182,7 @@ pub fn view<'a>( locale, active_tab_is_post, active_tab_is_post_or_media, + git_file_history, )); } let right = container(right_col.width(Length::Fill).height(Length::Fill)) @@ -202,6 +209,8 @@ pub fn view<'a>( active_tab, locale, data_dir, + git_state, + offline_mode, )); // Resize drag handle: 4px wide strip between sidebar and content @@ -379,6 +388,7 @@ fn route_content_area<'a>( site_validation_state: &'a SiteValidationState, metadata_diff_state: &'a MetadataDiffState, translation_validation_state: &'a TranslationValidationState, + git_diffs: &'a HashMap, ) -> Element<'a, Message> { match route_kind( tabs, @@ -454,6 +464,19 @@ fn route_content_area<'a>( ContentRoute::TranslationValidation => { translation_validation::view(translation_validation_state, locale) } + ContentRoute::GitDiff(tab_id) => { + if let Some(state) = git_diffs.get(tab_id) { + let settings = settings_state.cloned().unwrap_or_default(); + git::diff_view( + state, + &settings.diff_view_style, + settings.wrap_long_lines, + locale, + ) + } else { + loading_view(locale) + } + } ContentRoute::Placeholder(title) => welcome::tab_placeholder(locale, title, None), } } @@ -472,6 +495,7 @@ enum ContentRoute<'a> { SiteValidation, MetadataDiff, TranslationValidation, + GitDiff(&'a str), Placeholder(&'a str), } @@ -547,11 +571,11 @@ fn route_kind<'a>( } TabType::SiteValidation => ContentRoute::SiteValidation, TabType::MetadataDiff => ContentRoute::MetadataDiff, + TabType::GitDiff => ContentRoute::GitDiff(tab_id), TabType::Style | TabType::Chat | TabType::Import | TabType::MenuEditor - | TabType::GitDiff | TabType::Documentation | TabType::ApiDocumentation | TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title), @@ -615,7 +639,6 @@ mod tests { TabType::Chat, TabType::Import, TabType::MenuEditor, - TabType::GitDiff, TabType::Documentation, TabType::ApiDocumentation, TabType::FindDuplicates, @@ -642,6 +665,29 @@ mod tests { } } + #[test] + fn git_diff_tab_routes_to_real_view() { + let empty_posts = HashMap::new(); + let empty_media = HashMap::new(); + let empty_templates = HashMap::new(); + let empty_scripts = HashMap::new(); + let site_validation_state = SiteValidationState::default(); + let tabs = vec![tab("git-diff:file.txt", TabType::GitDiff, "file.txt")]; + let route = route_kind( + &tabs, + Some("git-diff:file.txt"), + &empty_posts, + &empty_media, + &empty_templates, + &empty_scripts, + None, + None, + None, + &site_validation_state, + ); + assert!(matches!(route, ContentRoute::GitDiff("git-diff:file.txt"))); + } + #[test] fn validation_tabs_route_to_real_views() { let empty_posts = HashMap::new(); diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 0d40ffb..2ac6299 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -65,7 +65,6 @@ panel-output = Ausgabe panel-postLinks = Beitragsverknüpfungen panel-gitLog = Git-Verlauf panel-postLinksPlaceholder = Keine Beitragsverknüpfungen vorhanden -panel-gitLogPlaceholder = Kein Git-Verlauf vorhanden panel-closeTitle = Panel schließen panel-noRecentTasks = Keine aktuellen Aufgaben panel-noOutput = Keine Ausgabe @@ -123,7 +122,6 @@ sidebar-noScriptsYet = Noch keine Skripte sidebar-noTemplatesYet = Noch keine Vorlagen sidebar-chatPlaceholder = KI-Assistent erscheint hier sidebar-importPlaceholder = Inhalte aus externen Quellen importieren -sidebar-gitPlaceholder = Versionskontrolle-Integration sidebar-loading = Lädt... sidebar-settingsHeader = Einstellungen sidebar-tagsHeader = Schlagwörter @@ -489,3 +487,32 @@ find-replace = Ersetzen find-replaceAll = Alle ersetzen find-noMatches = Keine Treffer gefunden find-replacedCount = { $count } Treffer ersetzt +git-loading = Repository wird geladen… +git-notRepository = Dieses Projekt ist noch kein Git-Repository. +git-remoteOptional = Remote-URL (optional) +git-initialize = Git initialisieren +git-fetch = Abrufen +git-pull = Herunterladen +git-push = Hochladen +git-airplaneBlocked = Netzwerk-Git-Aktionen sind im Flugmodus nicht verfügbar. +git-remoteUrl = Origin-URL +git-saveRemote = Speichern +git-changesCount = Änderungen ({ $count }) +git-commitMessage = Commit-Nachricht +git-commit = Commit +git-noChanges = Keine Änderungen +git-history = Verlauf +git-noCommits = Noch keine Commits +git-pruneLfs = LFS-Cache bereinigen +git-liveOutput = Live-Ausgabe +git-diff = Git-Vergleich +git-commitDiffTitle = Commit { $hash } +git-readOnly = Schreibgeschützter Git-Vergleich +git-loadingDiff = Vergleich wird geladen… +git-original = Original +git-modified = Geändert +git-inlineDiff = Inline-Vergleich +git-noDiff = Keine Unterschiede +git-noFileHistory = Kein Verlauf für diese Datei +git-operationDone = Git { $operation } abgeschlossen +git-runningOperation = Git { $operation } diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 526f43e..026d895 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -65,7 +65,6 @@ panel-output = Output panel-postLinks = Post Links panel-gitLog = Git Log panel-postLinksPlaceholder = No post links to display -panel-gitLogPlaceholder = No git history to display panel-closeTitle = Close panel panel-noRecentTasks = No recent tasks panel-noOutput = No output @@ -123,7 +122,6 @@ sidebar-noScriptsYet = No scripts yet sidebar-noTemplatesYet = No templates yet sidebar-chatPlaceholder = AI assistant will appear here sidebar-importPlaceholder = Import content from external sources -sidebar-gitPlaceholder = Source control integration sidebar-loading = Loading... sidebar-settingsHeader = Settings sidebar-tagsHeader = Tags @@ -489,3 +487,32 @@ find-replace = Replace find-replaceAll = Replace All find-noMatches = No matches found find-replacedCount = Replaced { $count } matches +git-loading = Loading repository… +git-notRepository = This project is not a Git repository yet. +git-remoteOptional = Remote URL (optional) +git-initialize = Initialize Git +git-fetch = Fetch +git-pull = Pull +git-push = Push +git-airplaneBlocked = Network Git actions are unavailable in airplane mode. +git-remoteUrl = Origin URL +git-saveRemote = Save +git-changesCount = Changes ({ $count }) +git-commitMessage = Commit message +git-commit = Commit +git-noChanges = No changes +git-history = History +git-noCommits = No commits yet +git-pruneLfs = Prune LFS cache +git-liveOutput = Live output +git-diff = Git Diff +git-commitDiffTitle = Commit { $hash } +git-readOnly = Read-only Git comparison +git-loadingDiff = Loading diff… +git-original = Original +git-modified = Modified +git-inlineDiff = Inline diff +git-noDiff = No differences +git-noFileHistory = No history for this file +git-operationDone = Git { $operation } completed +git-runningOperation = Git { $operation } diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 2412de4..f9b45ff 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -65,7 +65,6 @@ panel-output = Salida panel-postLinks = Enlaces de entradas panel-gitLog = Historial de Git panel-postLinksPlaceholder = No hay enlaces de entradas para mostrar -panel-gitLogPlaceholder = No hay historial de Git para mostrar panel-closeTitle = Cerrar panel panel-noRecentTasks = No hay tareas recientes panel-noOutput = Sin salida @@ -123,7 +122,6 @@ sidebar-noScriptsYet = Aún no hay scripts sidebar-noTemplatesYet = Aún no hay plantillas sidebar-chatPlaceholder = El asistente IA aparecerá aquí sidebar-importPlaceholder = Importar contenido desde fuentes externas -sidebar-gitPlaceholder = Integración del control de código fuente sidebar-loading = Cargando... sidebar-settingsHeader = Configuración sidebar-tagsHeader = Etiquetas @@ -489,3 +487,32 @@ find-replace = Reemplazar find-replaceAll = Reemplazar todo find-noMatches = No se encontraron coincidencias find-replacedCount = Se reemplazaron { $count } coincidencias +git-loading = Cargando repositorio… +git-notRepository = Este proyecto aún no es un repositorio Git. +git-remoteOptional = URL remota (opcional) +git-initialize = Inicializar Git +git-fetch = Obtener +git-pull = Descargar +git-push = Subir +git-airplaneBlocked = Las acciones Git de red no están disponibles en modo avión. +git-remoteUrl = URL de origin +git-saveRemote = Guardar +git-changesCount = Cambios ({ $count }) +git-commitMessage = Mensaje de commit +git-commit = Commit +git-noChanges = Sin cambios +git-history = Historial +git-noCommits = Aún no hay commits +git-pruneLfs = Limpiar caché LFS +git-liveOutput = Salida en directo +git-diff = Comparación Git +git-commitDiffTitle = Commit { $hash } +git-readOnly = Comparación Git de solo lectura +git-loadingDiff = Cargando comparación… +git-original = Original +git-modified = Modificado +git-inlineDiff = Comparación en línea +git-noDiff = Sin diferencias +git-noFileHistory = No hay historial para este archivo +git-operationDone = Git { $operation } completado +git-runningOperation = Git { $operation } diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 5548dec..e3702ed 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -65,7 +65,6 @@ panel-output = Sortie panel-postLinks = Liens d'articles panel-gitLog = Historique Git panel-postLinksPlaceholder = Aucun lien d'article à afficher -panel-gitLogPlaceholder = Aucun historique Git à afficher panel-closeTitle = Fermer le panneau panel-noRecentTasks = Aucune tâche récente panel-noOutput = Aucune sortie @@ -123,7 +122,6 @@ sidebar-noScriptsYet = Aucun script sidebar-noTemplatesYet = Aucun modèle sidebar-chatPlaceholder = L'assistant IA apparaîtra ici sidebar-importPlaceholder = Importer du contenu depuis des sources externes -sidebar-gitPlaceholder = Intégration du contrôle de source sidebar-loading = Chargement... sidebar-settingsHeader = Paramètres sidebar-tagsHeader = Étiquettes @@ -489,3 +487,32 @@ find-replace = Remplacer find-replaceAll = Tout remplacer find-noMatches = Aucun résultat find-replacedCount = { $count } résultats remplacés +git-loading = Chargement du dépôt… +git-notRepository = Ce projet n’est pas encore un dépôt Git. +git-remoteOptional = URL distante (facultative) +git-initialize = Initialiser Git +git-fetch = Récupérer +git-pull = Tirer +git-push = Pousser +git-airplaneBlocked = Les actions Git réseau sont indisponibles en mode avion. +git-remoteUrl = URL d’origine +git-saveRemote = Enregistrer +git-changesCount = Modifications ({ $count }) +git-commitMessage = Message de commit +git-commit = Commit +git-noChanges = Aucune modification +git-history = Historique +git-noCommits = Aucun commit +git-pruneLfs = Nettoyer le cache LFS +git-liveOutput = Sortie en direct +git-diff = Comparaison Git +git-commitDiffTitle = Commit { $hash } +git-readOnly = Comparaison Git en lecture seule +git-loadingDiff = Chargement de la comparaison… +git-original = Original +git-modified = Modifié +git-inlineDiff = Comparaison intégrée +git-noDiff = Aucune différence +git-noFileHistory = Aucun historique pour ce fichier +git-operationDone = Git { $operation } terminé +git-runningOperation = Git { $operation } diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 0f4186d..fb1998e 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -65,7 +65,6 @@ panel-output = Uscita panel-postLinks = Link ai post panel-gitLog = Cronologia Git panel-postLinksPlaceholder = Nessun link ai post da visualizzare -panel-gitLogPlaceholder = Nessuna cronologia Git da visualizzare panel-closeTitle = Chiudi pannello panel-noRecentTasks = Nessuna attività recente panel-noOutput = Nessun output @@ -123,7 +122,6 @@ sidebar-noScriptsYet = Nessuno script sidebar-noTemplatesYet = Nessun modello sidebar-chatPlaceholder = L'assistente IA apparirà qui sidebar-importPlaceholder = Importa contenuti da fonti esterne -sidebar-gitPlaceholder = Integrazione del controllo sorgente sidebar-loading = Caricamento... sidebar-settingsHeader = Impostazioni sidebar-tagsHeader = Tag @@ -489,3 +487,32 @@ find-replace = Sostituisci find-replaceAll = Sostituisci tutto find-noMatches = Nessuna corrispondenza find-replacedCount = Sostituite { $count } corrispondenze +git-loading = Caricamento del repository… +git-notRepository = Questo progetto non è ancora un repository Git. +git-remoteOptional = URL remoto (facoltativo) +git-initialize = Inizializza Git +git-fetch = Recupera +git-pull = Scarica +git-push = Carica +git-airplaneBlocked = Le azioni Git di rete non sono disponibili in modalità aereo. +git-remoteUrl = URL origin +git-saveRemote = Salva +git-changesCount = Modifiche ({ $count }) +git-commitMessage = Messaggio di commit +git-commit = Commit +git-noChanges = Nessuna modifica +git-history = Cronologia +git-noCommits = Nessun commit +git-pruneLfs = Pulisci cache LFS +git-liveOutput = Output in tempo reale +git-diff = Confronto Git +git-commitDiffTitle = Commit { $hash } +git-readOnly = Confronto Git in sola lettura +git-loadingDiff = Caricamento confronto… +git-original = Originale +git-modified = Modificato +git-inlineDiff = Confronto in linea +git-noDiff = Nessuna differenza +git-noFileHistory = Nessuna cronologia per questo file +git-operationDone = Git { $operation } completato +git-runningOperation = Git { $operation }