aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-vcs/src/downloader/git.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart-vcs/src/downloader/git.rs')
-rw-r--r--crates/mozart-vcs/src/downloader/git.rs156
1 files changed, 153 insertions, 3 deletions
diff --git a/crates/mozart-vcs/src/downloader/git.rs b/crates/mozart-vcs/src/downloader/git.rs
index 3bdb9ca..0c78f89 100644
--- a/crates/mozart-vcs/src/downloader/git.rs
+++ b/crates/mozart-vcs/src/downloader/git.rs
@@ -1,12 +1,18 @@
use std::path::Path;
+use std::sync::LazyLock;
use anyhow::Result;
+use regex::Regex;
use crate::process::ProcessExecutor;
use crate::util::git::GitUtil;
use super::VcsDownloader;
+/// Match `<hex> HEAD` lines in `git show-ref --head -d` output.
+static HEAD_REF_RE: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"(?im)^([a-f0-9]+) HEAD$").unwrap());
+
/// Git downloader using clone/checkout with optional mirror cache.
///
/// Corresponds to Composer's `Downloader\GitDownloader`.
@@ -91,13 +97,121 @@ impl VcsDownloader for GitDownloader {
}
fn local_changes(&self, target: &Path) -> Result<Option<String>> {
+ if !target.join(".git").exists() {
+ return Ok(None);
+ }
+ let process = ProcessExecutor::new();
+ let output = process.execute(
+ &["git", "status", "--porcelain", "--untracked-files=no"],
+ Some(target),
+ )?;
+ let trimmed = output.stdout.trim();
+ if trimmed.is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(trimmed.to_string()))
+ }
+ }
+
+ fn vcs_reference(&self, target: &Path) -> Result<Option<String>> {
+ if !target.join(".git").exists() {
+ return Ok(None);
+ }
let process = ProcessExecutor::new();
- let output = process.execute(&["git", "status", "--porcelain"], Some(target))?;
- if output.stdout.trim().is_empty() {
+ let output = process.execute(&["git", "rev-parse", "HEAD"], Some(target))?;
+ if output.status != 0 {
+ return Ok(None);
+ }
+ let trimmed = output.stdout.trim();
+ if trimmed.is_empty() {
Ok(None)
} else {
- Ok(Some(output.stdout))
+ Ok(Some(trimmed.to_string()))
+ }
+ }
+
+ fn unpushed_changes(&self, target: &Path) -> Result<Option<String>> {
+ if !target.join(".git").exists() {
+ return Ok(None);
+ }
+ let process = ProcessExecutor::new();
+
+ let mut refs = match collect_show_ref(&process, target)? {
+ Some(r) => r,
+ None => return Ok(None),
+ };
+
+ let head_ref = match HEAD_REF_RE
+ .captures(&refs)
+ .and_then(|c| c.get(1))
+ .map(|m| m.as_str().to_string())
+ {
+ Some(h) => h,
+ None => return Ok(None),
+ };
+
+ let candidate_branches = collect_local_branches(&refs, &head_ref);
+ if candidate_branches.is_empty() {
+ // not on a branch (detached / tag) — skip
+ return Ok(None);
+ }
+
+ let mut branch = candidate_branches[0].clone();
+ let mut unpushed_changes: Option<String> = None;
+ let mut branch_not_found_error = false;
+
+ for i in 0..=1 {
+ let mut remote_branches: Vec<String> = Vec::new();
+
+ for candidate in &candidate_branches {
+ let matches = collect_remote_branches(&refs, candidate);
+ if !matches.is_empty() {
+ branch = candidate.clone();
+ remote_branches = matches;
+ break;
+ }
+ }
+
+ if remote_branches.is_empty() {
+ unpushed_changes = Some(format!(
+ "Branch {branch} could not be found on any remote and appears to be unpushed"
+ ));
+ branch_not_found_error = true;
+ } else {
+ if branch_not_found_error {
+ unpushed_changes = None;
+ }
+ for remote_branch in &remote_branches {
+ let range = format!("{remote_branch}...{branch}");
+ let output = process.execute_checked(
+ &["git", "diff", "--name-status", &range, "--"],
+ Some(target),
+ )?;
+ let trimmed = output.stdout.trim().to_string();
+ match unpushed_changes {
+ None => unpushed_changes = Some(trimmed),
+ Some(ref existing) if trimmed.len() < existing.len() => {
+ unpushed_changes = Some(trimmed);
+ }
+ _ => {}
+ }
+ }
+ }
+
+ if unpushed_changes.as_deref().is_some_and(|s| !s.is_empty()) && i == 0 {
+ let _ = process.execute(&["git", "fetch", "--all"], Some(target))?;
+ refs = match collect_show_ref(&process, target)? {
+ Some(r) => r,
+ None => return Ok(unpushed_changes),
+ };
+ }
+
+ if unpushed_changes.as_deref().is_none_or(str::is_empty) {
+ break;
+ }
}
+
+ Ok(unpushed_changes.filter(|s| !s.is_empty()))
}
fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String> {
@@ -110,3 +224,39 @@ impl VcsDownloader for GitDownloader {
Ok(output.stdout)
}
}
+
+fn collect_show_ref(process: &ProcessExecutor, target: &Path) -> Result<Option<String>> {
+ let output = process.execute(&["git", "show-ref", "--head", "-d"], Some(target))?;
+ if output.status != 0 {
+ anyhow::bail!(
+ "Failed to execute git show-ref --head -d\n\n{}",
+ output.stderr.trim()
+ );
+ }
+ Ok(Some(output.stdout.trim().to_string()))
+}
+
+fn collect_local_branches(refs: &str, head_ref: &str) -> Vec<String> {
+ let pattern = format!(r"(?im)^{} refs/heads/(.+)$", regex::escape(head_ref));
+ let re = match Regex::new(&pattern) {
+ Ok(r) => r,
+ Err(_) => return Vec::new(),
+ };
+ re.captures_iter(refs)
+ .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
+ .collect()
+}
+
+fn collect_remote_branches(refs: &str, candidate: &str) -> Vec<String> {
+ let pattern = format!(
+ r"(?im)^[a-f0-9]+ refs/remotes/((?:[^/]+)/{})$",
+ regex::escape(candidate)
+ );
+ let re = match Regex::new(&pattern) {
+ Ok(r) => r,
+ Err(_) => return Vec::new(),
+ };
+ re.captures_iter(refs)
+ .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
+ .collect()
+}