feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
294
product-source/hololake-platform/src-tauri/src/git/author.rs
Normal file
294
product-source/hololake-platform/src-tauri/src/git/author.rs
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
use super::command::git_output_result;
|
||||
use super::run_git;
|
||||
|
||||
pub(crate) const FALLBACK_AUTHOR_NAME: &str = "Tolaria";
|
||||
pub(crate) const FALLBACK_AUTHOR_EMAIL: &str = "vault@tolaria.default";
|
||||
pub(crate) const LEGACY_FALLBACK_EMAIL: &str = "vault@tolaria.md";
|
||||
|
||||
const SOURCE_FALLBACK: &str = "fallback";
|
||||
const SOURCE_GLOBAL: &str = "global";
|
||||
const SOURCE_REPOSITORY: &str = "repository";
|
||||
const SOURCE_SYSTEM: &str = "system";
|
||||
const SOURCE_UNKNOWN: &str = "unknown";
|
||||
const SOURCE_ENVIRONMENT: &str = "environment";
|
||||
const WARNING_LOCAL_OVERRIDES_GLOBAL: &str = "local_overrides_global";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum AuthorConfigKey {
|
||||
Name,
|
||||
Email,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ConfigScope {
|
||||
Local,
|
||||
Global,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
|
||||
pub struct GitAuthorIdentity {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub source: String,
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct AuthorIdentity {
|
||||
name: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ScopedConfigValue {
|
||||
scope: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
pub fn git_author_identity(vault_path: &str) -> Result<GitAuthorIdentity, String> {
|
||||
let dir = Path::new(vault_path);
|
||||
|
||||
ensure_author_config(dir)?;
|
||||
let identity = resolved_git_author_identity(dir)?;
|
||||
let source = author_identity_source(dir, &identity.email)?;
|
||||
let warning = local_global_identity_warning(dir)?;
|
||||
|
||||
Ok(GitAuthorIdentity {
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
source,
|
||||
warning,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
heal_legacy_local_identity(dir)?;
|
||||
|
||||
for (key, fallback, skip_legacy) in [
|
||||
(AuthorConfigKey::Name, FALLBACK_AUTHOR_NAME, false),
|
||||
(AuthorConfigKey::Email, FALLBACK_AUTHOR_EMAIL, true),
|
||||
] {
|
||||
let key_name = match key {
|
||||
AuthorConfigKey::Name => "user.name",
|
||||
AuthorConfigKey::Email => "user.email",
|
||||
};
|
||||
let resolved = git_output_result(dir, &["config", key_name])
|
||||
.map_err(|e| format!("Failed to check git config {key_name}: {e}"))?;
|
||||
|
||||
let value = String::from_utf8_lossy(&resolved.stdout);
|
||||
let value = value.trim();
|
||||
if resolved.status.success() && resolved_author_value_is_usable(value, skip_legacy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
run_git(dir, &["config", "--local", key_name, fallback])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolved_author_value_is_usable(value: &str, skip_legacy: bool) -> bool {
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
!skip_legacy || value != LEGACY_FALLBACK_EMAIL
|
||||
}
|
||||
|
||||
fn heal_legacy_local_identity(dir: &Path) -> Result<(), String> {
|
||||
let local_email = local_config_value(dir, AuthorConfigKey::Email)?;
|
||||
if local_email.as_deref() != Some(LEGACY_FALLBACK_EMAIL) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run_git(dir, &["config", "--local", "--unset-all", "user.email"])?;
|
||||
if local_config_value(dir, AuthorConfigKey::Name)?.as_deref() == Some(FALLBACK_AUTHOR_NAME) {
|
||||
run_git(dir, &["config", "--local", "--unset-all", "user.name"])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn local_config_value(
|
||||
dir: &Path,
|
||||
key: AuthorConfigKey,
|
||||
) -> Result<Option<String>, String> {
|
||||
config_value(dir, ConfigScope::Local, key)
|
||||
}
|
||||
|
||||
fn global_config_value(dir: &Path, key: AuthorConfigKey) -> Result<Option<String>, String> {
|
||||
config_value(dir, ConfigScope::Global, key)
|
||||
}
|
||||
|
||||
fn config_value(
|
||||
dir: &Path,
|
||||
scope: ConfigScope,
|
||||
key: AuthorConfigKey,
|
||||
) -> Result<Option<String>, String> {
|
||||
let scope_flag = match scope {
|
||||
ConfigScope::Local => "--local",
|
||||
ConfigScope::Global => "--global",
|
||||
};
|
||||
let key_name = match key {
|
||||
AuthorConfigKey::Name => "user.name",
|
||||
AuthorConfigKey::Email => "user.email",
|
||||
};
|
||||
let output = git_output_result(dir, &["config", scope_flag, key_name])
|
||||
.map_err(|e| format!("Failed to check git config {key_name}: {e}"))?;
|
||||
|
||||
let value = String::from_utf8_lossy(&output.stdout);
|
||||
let value = value.trim();
|
||||
Ok((output.status.success() && !value.is_empty()).then(|| value.to_string()))
|
||||
}
|
||||
|
||||
fn resolved_git_author_identity(dir: &Path) -> Result<AuthorIdentity, String> {
|
||||
let output = git_output_result(dir, &["var", "GIT_AUTHOR_IDENT"])
|
||||
.map_err(|e| format!("Failed to resolve git author identity: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(author_identity_error(&output));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_author_ident(&stdout).ok_or_else(|| "Failed to parse git author identity".to_string())
|
||||
}
|
||||
|
||||
fn author_identity_error(output: &std::process::Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
stderr.trim()
|
||||
};
|
||||
format!("Failed to resolve git author identity: {detail}")
|
||||
}
|
||||
|
||||
fn parse_author_ident(value: &str) -> Option<AuthorIdentity> {
|
||||
let value = value.trim();
|
||||
let close = value.rfind('>')?;
|
||||
let before_email_close = &value[..close];
|
||||
let open = before_email_close.rfind('<')?;
|
||||
let name = before_email_close[..open].trim();
|
||||
let email = before_email_close[open + 1..].trim();
|
||||
|
||||
if name.is_empty() || email.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AuthorIdentity {
|
||||
name: name.to_string(),
|
||||
email: email.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn author_identity_source(dir: &Path, email: &str) -> Result<String, String> {
|
||||
if email == FALLBACK_AUTHOR_EMAIL {
|
||||
return Ok(SOURCE_FALLBACK.to_string());
|
||||
}
|
||||
|
||||
let Some(config) = scoped_config_value(dir, AuthorConfigKey::Email)? else {
|
||||
return Ok(SOURCE_UNKNOWN.to_string());
|
||||
};
|
||||
|
||||
if config.value != email {
|
||||
return Ok(SOURCE_ENVIRONMENT.to_string());
|
||||
}
|
||||
|
||||
Ok(scope_source(&config.scope).to_string())
|
||||
}
|
||||
|
||||
fn scoped_config_value(
|
||||
dir: &Path,
|
||||
key: AuthorConfigKey,
|
||||
) -> Result<Option<ScopedConfigValue>, String> {
|
||||
let key_name = match key {
|
||||
AuthorConfigKey::Name => "user.name",
|
||||
AuthorConfigKey::Email => "user.email",
|
||||
};
|
||||
let output = git_output_result(dir, &["config", "--show-scope", "--get", key_name])
|
||||
.map_err(|e| format!("Failed to check git config {key_name}: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(parse_scoped_config_value(&String::from_utf8_lossy(
|
||||
&output.stdout,
|
||||
)))
|
||||
}
|
||||
|
||||
fn parse_scoped_config_value(stdout: &str) -> Option<ScopedConfigValue> {
|
||||
let line = stdout.lines().next()?.trim();
|
||||
let (scope, value) = line.split_once('\t')?;
|
||||
let value = value.trim();
|
||||
(!scope.is_empty() && !value.is_empty()).then(|| ScopedConfigValue {
|
||||
scope: scope.to_string(),
|
||||
value: value.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn scope_source(scope: &str) -> &str {
|
||||
match scope {
|
||||
"local" | "worktree" => SOURCE_REPOSITORY,
|
||||
"global" => SOURCE_GLOBAL,
|
||||
"system" => SOURCE_SYSTEM,
|
||||
"command" => SOURCE_ENVIRONMENT,
|
||||
_ => SOURCE_UNKNOWN,
|
||||
}
|
||||
}
|
||||
|
||||
fn local_global_identity_warning(dir: &Path) -> Result<Option<String>, String> {
|
||||
let Some(local) = config_identity(dir, local_config_value)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(global) = config_identity(dir, global_config_value)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok((local != global).then(|| WARNING_LOCAL_OVERRIDES_GLOBAL.to_string()))
|
||||
}
|
||||
|
||||
fn config_identity(
|
||||
dir: &Path,
|
||||
reader: fn(&Path, AuthorConfigKey) -> Result<Option<String>, String>,
|
||||
) -> Result<Option<AuthorIdentity>, String> {
|
||||
let name = reader(dir, AuthorConfigKey::Name)?;
|
||||
let email = reader(dir, AuthorConfigKey::Email)?;
|
||||
|
||||
Ok(match (name, email) {
|
||||
(Some(name), Some(email)) => Some(AuthorIdentity { name, email }),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_author_ident_with_spaces_in_name() {
|
||||
let identity = parse_author_ident("Vault Owner <owner@example.com> 1781170560 +0200")
|
||||
.expect("author identity should parse");
|
||||
|
||||
assert_eq!(
|
||||
identity,
|
||||
AuthorIdentity {
|
||||
name: "Vault Owner".to_string(),
|
||||
email: "owner@example.com".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_scoped_config_value() {
|
||||
assert_eq!(
|
||||
parse_scoped_config_value("local\towner@example.com\n"),
|
||||
Some(ScopedConfigValue {
|
||||
scope: "local".to_string(),
|
||||
value: "owner@example.com".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
266
product-source/hololake-platform/src-tauri/src/git/clone.rs
Normal file
266
product-source/hololake-platform/src-tauri/src/git/clone.rs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
use std::path::Path;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use super::git_command;
|
||||
|
||||
struct CloneRequest<'a> {
|
||||
url: &'a str,
|
||||
dest: &'a Path,
|
||||
}
|
||||
|
||||
/// Clone a git repository to a local path using the system git configuration.
|
||||
pub fn clone_repo(url: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
let request = CloneRequest { url, dest };
|
||||
prepare_clone_destination(dest)?;
|
||||
|
||||
if let Err(err) = run_clone(&request) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(format!("Cloned to {}", dest.display()))
|
||||
}
|
||||
|
||||
fn prepare_clone_destination(dest: &Path) -> Result<(), String> {
|
||||
if !dest.exists() {
|
||||
return ensure_parent_directory(dest);
|
||||
}
|
||||
|
||||
ensure_empty_directory(dest)
|
||||
}
|
||||
|
||||
fn ensure_empty_directory(dest: &Path) -> Result<(), String> {
|
||||
if !dest.is_dir() {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not a directory",
|
||||
dest.display()
|
||||
));
|
||||
}
|
||||
|
||||
if directory_has_entries(dest)? {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
dest.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_parent_directory(dest: &Path) -> Result<(), String> {
|
||||
let Some(parent) = dest.parent() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if parent.as_os_str().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create parent directory for '{}': {}",
|
||||
dest.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn directory_has_entries(dest: &Path) -> Result<bool, String> {
|
||||
dest.read_dir()
|
||||
.map_err(|e| format!("Failed to inspect destination '{}': {}", dest.display(), e))
|
||||
.map(|mut entries| entries.next().is_some())
|
||||
}
|
||||
|
||||
fn run_clone(request: &CloneRequest<'_>) -> Result<(), String> {
|
||||
let destination = request.dest.to_str().ok_or_else(|| {
|
||||
format!(
|
||||
"Destination '{}' is not valid UTF-8",
|
||||
request.dest.display()
|
||||
)
|
||||
})?;
|
||||
let git_destination = super::git_path_argument(destination)?;
|
||||
let output = build_clone_command(request, &git_destination)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"git clone failed: {}",
|
||||
clone_failure_message(&output)
|
||||
))
|
||||
}
|
||||
|
||||
fn build_clone_command(request: &CloneRequest<'_>, destination: &str) -> Command {
|
||||
let mut command = git_command();
|
||||
command
|
||||
.args(["clone", "--quiet", "--", request.url, destination])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("SSH_ASKPASS_REQUIRE", "never")
|
||||
.stdin(Stdio::null());
|
||||
command
|
||||
}
|
||||
|
||||
fn clone_failure_message(output: &Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stderr = stderr.trim();
|
||||
if !stderr.is_empty() {
|
||||
return stderr.to_string();
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stdout = stdout.trim();
|
||||
if !stdout.is_empty() {
|
||||
return stdout.to_string();
|
||||
}
|
||||
|
||||
format!("git clone exited with status {}", output.status)
|
||||
}
|
||||
|
||||
fn cleanup_failed_clone(dest: &Path) {
|
||||
if dest.exists() && dest.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
fn init_source_repo(path: &Path) {
|
||||
fs::create_dir_all(path).unwrap();
|
||||
fs::write(path.join("welcome.md"), "# Welcome\n").unwrap();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", "tolaria@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Tolaria App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_clones_local_repository() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("source");
|
||||
let dest = dir.path().join("dest");
|
||||
init_source_repo(&source);
|
||||
|
||||
let result = clone_repo(source.to_str().unwrap(), dest.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(result, format!("Cloned to {}", dest.to_string_lossy()));
|
||||
assert!(dest.join(".git").exists());
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_nonempty_dest() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
fs::write(dir.path().join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo("https://example.com/repo.git", dir.path().to_str().unwrap());
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_empty_dest_allowed() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("empty-dir");
|
||||
fs::create_dir(&dest).unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://example.com/nonexistent/repo.git",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_failure_message_falls_back_to_stdout() {
|
||||
let output = Output {
|
||||
status: std::process::ExitStatus::from_raw(128),
|
||||
stdout: b"fatal: stdout only".to_vec(),
|
||||
stderr: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(clone_failure_message(&output), "fatal: stdout only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_clone_command_disables_interactive_prompts() {
|
||||
let dest = Path::new("/tmp/repo");
|
||||
let request = CloneRequest {
|
||||
url: "https://example.com/repo.git",
|
||||
dest,
|
||||
};
|
||||
let command = build_clone_command(&request, "/tmp/repo");
|
||||
let args = command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let envs = command
|
||||
.get_envs()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key.to_string_lossy().to_string(),
|
||||
value.map(|entry| entry.to_string_lossy().to_string()),
|
||||
)
|
||||
})
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
assert_eq!(
|
||||
envs.get("GIT_TERMINAL_PROMPT"),
|
||||
Some(&Some("0".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
envs.get("SSH_ASKPASS_REQUIRE"),
|
||||
Some(&Some("never".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-c".to_string(),
|
||||
"core.quotePath=false".to_string(),
|
||||
"-c".to_string(),
|
||||
"protocol.ext.allow=never".to_string(),
|
||||
"-c".to_string(),
|
||||
"protocol.file.allow=user".to_string(),
|
||||
"-c".to_string(),
|
||||
"core.fsmonitor=false".to_string(),
|
||||
"-c".to_string(),
|
||||
"core.sshCommand=ssh".to_string(),
|
||||
"clone".to_string(),
|
||||
"--quiet".to_string(),
|
||||
"--".to_string(),
|
||||
"https://example.com/repo.git".to_string(),
|
||||
"/tmp/repo".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::process::Output;
|
||||
|
||||
use super::git_command_at;
|
||||
|
||||
pub(super) fn git_output(dir: &Path, args: &[&str]) -> io::Result<Output> {
|
||||
git_command_at(dir)?.args(args).output()
|
||||
}
|
||||
|
||||
pub(super) fn git_output_result(dir: &Path, args: &[&str]) -> Result<Output, String> {
|
||||
git_output(dir, args).map_err(|e| format!("Failed to run git {}: {e}", git_command_label(args)))
|
||||
}
|
||||
|
||||
pub(super) fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let output = git_output_result(dir, args)?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(stderr_text(&output))
|
||||
}
|
||||
|
||||
pub(super) fn stdout_text(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
pub(super) fn stdout_lines(output: &Output) -> Vec<String> {
|
||||
stdout_text(output)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn stderr_text(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_string()
|
||||
}
|
||||
|
||||
pub(super) fn stderr_or_failure(command: &str, output: &Output) -> String {
|
||||
let stderr = stderr_text(output);
|
||||
if stderr.is_empty() {
|
||||
format!("{command} failed")
|
||||
} else {
|
||||
stderr
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
|
||||
if args.first() == Some(&"-c") {
|
||||
return args.get(2).copied().unwrap_or(args[0]);
|
||||
}
|
||||
|
||||
args[0]
|
||||
}
|
||||
274
product-source/hololake-platform/src-tauri/src/git/commit.rs
Normal file
274
product-source/hololake-platform/src-tauri/src/git/commit.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
use super::command::git_output_result;
|
||||
use super::{ensure_author_config, git_command_at};
|
||||
use std::path::Path;
|
||||
|
||||
struct CommitFailure {
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
/// Commit all changes with a message.
|
||||
pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Stage all changes
|
||||
let add = git_output_result(vault, &["add", "-A"])
|
||||
.map_err(|e| format!("Failed to run git add: {}", e))?;
|
||||
|
||||
if !add.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&add.stderr);
|
||||
return Err(format!("git add failed: {}", stderr));
|
||||
}
|
||||
|
||||
ensure_author_config(vault)?;
|
||||
|
||||
match run_commit(vault, message, false) {
|
||||
Ok(stdout) => Ok(stdout),
|
||||
Err(failure) if is_commit_signing_failure(&failure.detail()) => {
|
||||
run_commit(vault, message, true).map_err(|retry_failure| {
|
||||
format!(
|
||||
"git commit signing failed; retried without signing but git commit still failed: {}",
|
||||
retry_failure.detail()
|
||||
)
|
||||
})
|
||||
}
|
||||
Err(failure) => Err(format!("git commit failed: {}", failure.detail())),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_commit(vault: &Path, message: &str, disable_signing: bool) -> Result<String, CommitFailure> {
|
||||
let mut command = git_command_at(vault).map_err(|e| CommitFailure {
|
||||
stdout: String::new(),
|
||||
stderr: format!("Failed to run git commit: {}", e),
|
||||
})?;
|
||||
if disable_signing {
|
||||
command.args(["-c", "commit.gpgsign=false"]);
|
||||
}
|
||||
|
||||
let commit = command
|
||||
.args(["commit", "-m", message])
|
||||
.output()
|
||||
.map_err(|e| CommitFailure {
|
||||
stdout: String::new(),
|
||||
stderr: format!("Failed to run git commit: {}", e),
|
||||
})?;
|
||||
|
||||
if commit.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&commit.stdout).to_string());
|
||||
}
|
||||
|
||||
Err(CommitFailure {
|
||||
stdout: String::from_utf8_lossy(&commit.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&commit.stderr).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
impl CommitFailure {
|
||||
fn detail(&self) -> String {
|
||||
// git writes "nothing to commit" to stdout, not stderr.
|
||||
let detail = if self.stderr.trim().is_empty() {
|
||||
&self.stdout
|
||||
} else {
|
||||
&self.stderr
|
||||
};
|
||||
detail.trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_commit_signing_failure(detail: &str) -> bool {
|
||||
let lower = detail.to_ascii_lowercase();
|
||||
lower.contains("cannot run gpg")
|
||||
|| lower.contains("gpg failed to sign")
|
||||
|| lower.contains("failed to sign the data")
|
||||
|| lower.contains("gpg.ssh")
|
||||
|| (lower.contains("failed to write commit object")
|
||||
&& (lower.contains("sign") || lower.contains("gpg")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_command;
|
||||
use crate::git::tests::{setup_git_repo, GitConfigEnvGuard};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn unset_local_author_config(vault: &Path) {
|
||||
for key in ["user.name", "user.email"] {
|
||||
let status = git_command()
|
||||
.args(["config", "--local", "--unset-all", key])
|
||||
.current_dir(vault)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "failed to unset {key}");
|
||||
}
|
||||
}
|
||||
|
||||
fn local_config_value(vault: &Path, key: &str) -> Option<String> {
|
||||
let output = git_command()
|
||||
.args(["config", "--local", key])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
fs::write(vault.join("commit-test.md"), "# Test\n").unwrap();
|
||||
|
||||
let result = git_commit(vault.to_str().unwrap(), "Test commit");
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify the commit exists
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Test commit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_commit_sets_missing_local_author_identity() {
|
||||
let _env = GitConfigEnvGuard::isolated();
|
||||
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
unset_local_author_config(vault);
|
||||
|
||||
fs::write(vault.join("identity-fallback.md"), "# Identity fallback\n").unwrap();
|
||||
|
||||
let result = git_commit(vault.to_str().unwrap(), "Commit without local identity");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"commit should set local fallback identity: {result:?}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
local_config_value(vault, "user.name").as_deref(),
|
||||
Some("Tolaria")
|
||||
);
|
||||
assert_eq!(
|
||||
local_config_value(vault, "user.email").as_deref(),
|
||||
Some("vault@tolaria.default")
|
||||
);
|
||||
|
||||
let author = git_command()
|
||||
.args(["log", "-1", "--format=%an <%ae>"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&author.stdout).trim(),
|
||||
"Tolaria <vault@tolaria.default>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_commit_respects_global_author_identity() {
|
||||
let _env =
|
||||
GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com")));
|
||||
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
unset_local_author_config(vault);
|
||||
|
||||
fs::write(vault.join("global-identity.md"), "# Global identity\n").unwrap();
|
||||
|
||||
let result = git_commit(vault.to_str().unwrap(), "Commit with global identity");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"commit should use the global identity: {result:?}"
|
||||
);
|
||||
|
||||
// The global identity resolves, so no local override is written.
|
||||
assert_eq!(local_config_value(vault, "user.name"), None);
|
||||
assert_eq!(local_config_value(vault, "user.email"), None);
|
||||
|
||||
let author = git_command()
|
||||
.args(["log", "-1", "--format=%an <%ae>"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&author.stdout).trim(),
|
||||
"Global User <global@test.com>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_nothing_to_commit_returns_error() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
// Create and commit, so working tree is clean
|
||||
fs::write(vault.join("clean.md"), "# Clean\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
// Committing again with no changes should fail
|
||||
let result = git_commit(vp, "nothing here");
|
||||
assert!(result.is_err(), "Commit should fail when nothing to commit");
|
||||
assert!(
|
||||
result.unwrap_err().contains("nothing to commit"),
|
||||
"Error should mention 'nothing to commit'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_commit_retries_without_signing_when_gpg_is_missing() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
git_command()
|
||||
.args(["config", "commit.gpgsign", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
fs::write(vault.join("signed-config.md"), "# Signed config\n").unwrap();
|
||||
|
||||
let result = git_commit(vp, "Commit with broken signing config");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"commit should retry unsigned when signing helper is missing: {result:?}"
|
||||
);
|
||||
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&log.stdout).contains("Commit with broken signing config"));
|
||||
|
||||
let config = git_command()
|
||||
.args(["config", "commit.gpgsign"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8_lossy(&config.stdout).trim(), "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_signing_failure_detection_is_specific() {
|
||||
assert!(is_commit_signing_failure(
|
||||
"error: cannot run gpg: No such file or directory\nfatal: failed to write commit object"
|
||||
));
|
||||
assert!(!is_commit_signing_failure(
|
||||
"On branch main\nnothing to commit, working tree clean"
|
||||
));
|
||||
}
|
||||
}
|
||||
414
product-source/hololake-platform/src-tauri/src/git/conflict.rs
Normal file
414
product-source/hololake-platform/src-tauri/src/git/conflict.rs
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
use std::path::Path;
|
||||
|
||||
use super::command::git_output_result;
|
||||
use super::{ensure_author_config, git_command_at, run_git};
|
||||
|
||||
/// List files with merge conflicts (unmerged paths).
|
||||
///
|
||||
/// Uses `git ls-files --unmerged` instead of `git diff --diff-filter=U` because
|
||||
/// ls-files reliably detects unmerged index entries even when the merge state is
|
||||
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
|
||||
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = git_output_result(vault, &["ls-files", "--unmerged"])
|
||||
.map_err(|e| format!("Failed to check conflicts: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// Each unmerged file appears multiple times (once per stage: base/ours/theirs).
|
||||
// Format: "<mode> <hash> <stage>\t<path>"
|
||||
let mut files: Vec<String> = stdout
|
||||
.lines()
|
||||
.filter_map(|line| line.split('\t').nth(1).map(|s| s.to_string()))
|
||||
.collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Resolve a single conflict file by choosing "ours" or "theirs" strategy,
|
||||
/// then stage the result.
|
||||
pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let checkout_flag = match strategy {
|
||||
"ours" => "--ours",
|
||||
"theirs" => "--theirs",
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Invalid strategy '{}': must be 'ours' or 'theirs'",
|
||||
strategy
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
run_git(vault, &["checkout", checkout_flag, "--", file])?;
|
||||
run_git(vault, &["add", "--", file])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether a rebase is currently in progress.
|
||||
pub fn is_rebase_in_progress(vault_path: &str) -> bool {
|
||||
let vault = Path::new(vault_path);
|
||||
let git_dir = vault.join(".git");
|
||||
git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists()
|
||||
}
|
||||
|
||||
/// Check whether a merge is currently in progress.
|
||||
pub fn is_merge_in_progress(vault_path: &str) -> bool {
|
||||
Path::new(vault_path)
|
||||
.join(".git")
|
||||
.join("MERGE_HEAD")
|
||||
.exists()
|
||||
}
|
||||
|
||||
/// Returns the current conflict mode: "rebase", "merge", or "none".
|
||||
pub fn get_conflict_mode(vault_path: &str) -> String {
|
||||
if is_rebase_in_progress(vault_path) {
|
||||
"rebase".to_string()
|
||||
} else if is_merge_in_progress(vault_path) {
|
||||
"merge".to_string()
|
||||
} else {
|
||||
"none".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit after all conflicts have been resolved.
|
||||
/// Detects whether the repo is in a merge or rebase state and uses the
|
||||
/// appropriate command (`git commit` vs `git rebase --continue`).
|
||||
pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Verify no remaining conflicts
|
||||
let remaining = get_conflict_files(vault_path)?;
|
||||
if !remaining.is_empty() {
|
||||
return Err(format!(
|
||||
"Cannot commit: {} file(s) still have unresolved conflicts",
|
||||
remaining.len()
|
||||
));
|
||||
}
|
||||
|
||||
ensure_author_config(vault)?;
|
||||
|
||||
let mode = get_conflict_mode(vault_path);
|
||||
let output = match mode.as_str() {
|
||||
"rebase" => git_command_at(vault)
|
||||
.and_then(|mut command| {
|
||||
command
|
||||
.args(["rebase", "--continue"])
|
||||
.env("GIT_EDITOR", "true")
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
|
||||
_ => git_command_at(vault)
|
||||
.and_then(|mut command| {
|
||||
command
|
||||
.args(["commit", "-m", "Resolve merge conflicts"])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| format!("Failed to run git commit: {}", e))?,
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
let cmd_name = if mode == "rebase" {
|
||||
"git rebase --continue"
|
||||
} else {
|
||||
"git commit"
|
||||
};
|
||||
return Err(format!("{} failed: {}", cmd_name, detail.trim()));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_command;
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair, GitConfigEnvGuard};
|
||||
use crate::git::{git_commit, git_pull, git_push};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn unset_local_author_config(vault: &Path) {
|
||||
for key in ["user.name", "user.email"] {
|
||||
let status = git_command()
|
||||
.args(["config", "--local", "--unset-all", key])
|
||||
.current_dir(vault)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "failed to unset {key}");
|
||||
}
|
||||
}
|
||||
|
||||
fn local_config_value(vault: &Path, key: &str) -> Option<String> {
|
||||
let output = git_command()
|
||||
.args(["config", "--local", key])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_conflict_files_empty_when_clean() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp).unwrap();
|
||||
assert!(conflicts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_invalid_strategy() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let result = git_resolve_conflict(vp_b, "conflict.md", "invalid");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid strategy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_mode_none_for_clean_repo() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp), "none");
|
||||
assert!(!is_rebase_in_progress(vp));
|
||||
assert!(!is_merge_in_progress(vp));
|
||||
}
|
||||
|
||||
/// Set up a pair of clones that have a merge conflict on the same file.
|
||||
/// Returns (bare, clone_a, clone_b) where clone_b has an unresolved conflict.
|
||||
fn setup_conflict_pair() -> (TempDir, TempDir, TempDir) {
|
||||
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
|
||||
|
||||
let vp_a = clone_a_dir.path().to_str().unwrap();
|
||||
let vp_b = clone_b_dir.path().to_str().unwrap();
|
||||
|
||||
// A creates the file and pushes
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
|
||||
git_commit(vp_a, "create conflict.md").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
// B pulls to get the file
|
||||
git_pull(vp_b).unwrap();
|
||||
|
||||
// A modifies and pushes
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
|
||||
git_commit(vp_a, "A's change").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
// B modifies the same file locally and commits
|
||||
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
|
||||
git_commit(vp_b, "B's change").unwrap();
|
||||
|
||||
// B pulls — this causes a merge conflict
|
||||
let result = git_pull(vp_b).unwrap();
|
||||
assert_eq!(result.status, "conflict");
|
||||
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
fn assert_resolve_conflict_strategy(strategy: &str, expected_content: &str) {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp_b).unwrap();
|
||||
assert!(conflicts.contains(&"conflict.md".to_string()));
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", strategy).unwrap();
|
||||
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
||||
assert_eq!(content, expected_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_ours() {
|
||||
assert_resolve_conflict_strategy("ours", "# Version B\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_theirs() {
|
||||
assert_resolve_conflict_strategy("theirs", "# Version A\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(clone_b.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Resolve merge conflicts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution_fails_with_unresolved() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.contains("still have unresolved conflicts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_mode_merge_during_merge_conflict() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "merge");
|
||||
assert!(is_merge_in_progress(vp_b));
|
||||
assert!(!is_rebase_in_progress(vp_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution_merge_mode() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "merge");
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution_sets_missing_local_author_identity() {
|
||||
let _env = GitConfigEnvGuard::isolated();
|
||||
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vault = clone_b.path();
|
||||
let vp_b = vault.to_str().unwrap();
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
unset_local_author_config(vault);
|
||||
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"conflict commit should set local fallback identity: {result:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
local_config_value(vault, "user.name").as_deref(),
|
||||
Some("Tolaria")
|
||||
);
|
||||
assert_eq!(
|
||||
local_config_value(vault, "user.email").as_deref(),
|
||||
Some("vault@tolaria.default")
|
||||
);
|
||||
}
|
||||
|
||||
/// Set up a rebase conflict: clone_b has diverged from origin and
|
||||
/// `git pull --rebase` causes a conflict.
|
||||
fn setup_rebase_conflict_pair() -> (TempDir, TempDir, TempDir) {
|
||||
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
|
||||
|
||||
let vp_a = clone_a_dir.path().to_str().unwrap();
|
||||
let vp_b = clone_b_dir.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
|
||||
git_commit(vp_a, "create conflict.md").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
git_pull(vp_b).unwrap();
|
||||
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
|
||||
git_commit(vp_a, "A's change").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
|
||||
git_commit(vp_b, "B's change").unwrap();
|
||||
|
||||
let output = git_command()
|
||||
.args(["pull", "--rebase"])
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Expected rebase conflict, but pull succeeded"
|
||||
);
|
||||
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_mode_rebase_during_rebase_conflict() {
|
||||
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "rebase");
|
||||
assert!(is_rebase_in_progress(vp_b));
|
||||
assert!(!is_merge_in_progress(vp_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_conflict_files_during_rebase() {
|
||||
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp_b).unwrap();
|
||||
assert!(
|
||||
conflicts.contains(&"conflict.md".to_string()),
|
||||
"Should detect conflict.md during rebase, got: {:?}",
|
||||
conflicts
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_and_continue_rebase() {
|
||||
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "rebase");
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok(), "rebase --continue failed: {:?}", result);
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "none");
|
||||
}
|
||||
}
|
||||
591
product-source/hololake-platform/src-tauri/src/git/connect.rs
Normal file
591
product-source/hololake-platform/src-tauri/src/git/connect.rs
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Output;
|
||||
|
||||
use super::command::{
|
||||
git_output, git_output_result, run_git, stderr_text, stdout_lines, stdout_text,
|
||||
};
|
||||
use super::credentials::request_remote_credentials;
|
||||
use super::ensure_author_config;
|
||||
use super::remote_config::{configure_origin_remote, list_configured_remotes};
|
||||
|
||||
const DEFAULT_REMOTE_NAME: &str = "origin";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ConnectStatus {
|
||||
Connected,
|
||||
AlreadyConfigured,
|
||||
IncompatibleHistory,
|
||||
AuthError,
|
||||
NetworkError,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ConnectStatus {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Connected => "connected",
|
||||
Self::AlreadyConfigured => "already_configured",
|
||||
Self::IncompatibleHistory => "incompatible_history",
|
||||
Self::AuthError => "auth_error",
|
||||
Self::NetworkError => "network_error",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitAddRemoteResult {
|
||||
pub status: String, // "connected" | "already_configured" | "incompatible_history" | "auth_error" | "network_error" | "error"
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
struct RemoteConnection {
|
||||
branch: String,
|
||||
remote_branch: String,
|
||||
}
|
||||
|
||||
impl RemoteConnection {
|
||||
fn new(branch: String) -> Self {
|
||||
let remote_branch = format!("{DEFAULT_REMOTE_NAME}/{branch}");
|
||||
Self {
|
||||
branch,
|
||||
remote_branch,
|
||||
}
|
||||
}
|
||||
|
||||
fn pushed_history_message(&self) -> String {
|
||||
format!(
|
||||
"Remote connected. Tolaria pushed your local commits and is now tracking {}.",
|
||||
self.remote_branch
|
||||
)
|
||||
}
|
||||
|
||||
fn tracking_message(&self) -> String {
|
||||
format!(
|
||||
"Remote connected. This vault now tracks {}.",
|
||||
self.remote_branch
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disconnect_all_remotes(vault_path: &str) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
for remote in list_remotes(vault)? {
|
||||
run_git(vault, &["remote", "remove", &remote])?;
|
||||
}
|
||||
|
||||
unset_upstream(vault);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result<GitAddRemoteResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if remote_url.trim().is_empty() {
|
||||
return Ok(connect_result(
|
||||
ConnectStatus::Error,
|
||||
"Enter a repository URL before connecting a remote.",
|
||||
));
|
||||
}
|
||||
|
||||
if !list_remotes(vault)?.is_empty() {
|
||||
return Ok(connect_result(
|
||||
ConnectStatus::AlreadyConfigured,
|
||||
"This vault already has a remote configured.",
|
||||
));
|
||||
}
|
||||
|
||||
ensure_author_config(vault)?;
|
||||
|
||||
let branch = current_branch(vault)?;
|
||||
if branch.is_empty() {
|
||||
return Ok(connect_result(
|
||||
ConnectStatus::Error,
|
||||
"Tolaria could not determine the current branch for this vault.",
|
||||
));
|
||||
}
|
||||
let connection = RemoteConnection::new(branch);
|
||||
|
||||
let trimmed_url = remote_url.trim();
|
||||
configure_origin_remote(vault, trimmed_url)?;
|
||||
request_remote_credentials(vault, trimmed_url);
|
||||
|
||||
let result = finish_remote_connection(vault, &connection);
|
||||
if result.status != "connected" {
|
||||
let _ = disconnect_all_remotes(vault_path);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn finish_remote_connection(vault: &Path, connection: &RemoteConnection) -> GitAddRemoteResult {
|
||||
if let Err(stderr) = fetch_remote(vault) {
|
||||
return classify_connect_error(&stderr);
|
||||
}
|
||||
|
||||
let remote_branches = match list_remote_branches(vault) {
|
||||
Ok(branches) => branches,
|
||||
Err(err) => return connect_result(ConnectStatus::Error, err),
|
||||
};
|
||||
|
||||
if remote_branches.is_empty() {
|
||||
return push_with_tracking(vault, connection, connection.pushed_history_message());
|
||||
}
|
||||
|
||||
if !remote_branches
|
||||
.iter()
|
||||
.any(|candidate| candidate == &connection.remote_branch)
|
||||
{
|
||||
return connect_result(
|
||||
ConnectStatus::IncompatibleHistory,
|
||||
format!(
|
||||
"This repository already has git branches, but not '{}'. Use an empty repository or one created from this vault.",
|
||||
connection.branch
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if !histories_share_base(vault, connection) {
|
||||
return connect_result(
|
||||
ConnectStatus::IncompatibleHistory,
|
||||
"This repository has unrelated history. Use an empty repository or one created from this vault.",
|
||||
);
|
||||
}
|
||||
|
||||
let (_, behind) = match ahead_behind_counts(vault, connection) {
|
||||
Ok(counts) => counts,
|
||||
Err(err) => return connect_result(ConnectStatus::Error, err),
|
||||
};
|
||||
|
||||
if behind > 0 {
|
||||
return connect_result(
|
||||
ConnectStatus::IncompatibleHistory,
|
||||
format!(
|
||||
"This repository already has commits on '{}' that are not in this vault. Tolaria will not connect it automatically.",
|
||||
connection.branch
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
push_with_tracking(vault, connection, connection.tracking_message())
|
||||
}
|
||||
|
||||
fn connect_result(status: ConnectStatus, message: impl Into<String>) -> GitAddRemoteResult {
|
||||
GitAddRemoteResult {
|
||||
status: status.as_str().to_string(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_branch(vault: &Path) -> Result<String, String> {
|
||||
let output = git_output_result(vault, &["branch", "--show-current"])?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(stdout_text(&output));
|
||||
}
|
||||
|
||||
Err(command_error("git branch --show-current", &output))
|
||||
}
|
||||
|
||||
fn list_remotes(vault: &Path) -> Result<Vec<String>, String> {
|
||||
list_configured_remotes(vault)
|
||||
}
|
||||
|
||||
fn unset_upstream(vault: &Path) {
|
||||
let _ = git_output(vault, &["branch", "--unset-upstream"]);
|
||||
}
|
||||
|
||||
fn fetch_remote(vault: &Path) -> Result<(), String> {
|
||||
run_git(vault, &["fetch", DEFAULT_REMOTE_NAME, "--prune"])
|
||||
}
|
||||
|
||||
fn list_remote_branches(vault: &Path) -> Result<Vec<String>, String> {
|
||||
let output = git_output_result(
|
||||
vault,
|
||||
&[
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
"refs/remotes/origin",
|
||||
],
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(command_error("git for-each-ref", &output));
|
||||
}
|
||||
|
||||
Ok(stdout_lines(&output)
|
||||
.into_iter()
|
||||
.filter(|line| line != "origin/HEAD")
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn histories_share_base(vault: &Path, connection: &RemoteConnection) -> bool {
|
||||
git_output(
|
||||
vault,
|
||||
&["merge-base", "HEAD", connection.remote_branch.as_str()],
|
||||
)
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn ahead_behind_counts(vault: &Path, connection: &RemoteConnection) -> Result<(u32, u32), String> {
|
||||
let revision_range = format!("HEAD...{}", connection.remote_branch);
|
||||
let output = git_output_result(
|
||||
vault,
|
||||
&["rev-list", "--left-right", "--count", &revision_range],
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(command_error("git rev-list", &output));
|
||||
}
|
||||
|
||||
let counts = stdout_text(&output);
|
||||
let parts: Vec<&str> = counts.trim().split('\t').collect();
|
||||
let ahead = parts
|
||||
.first()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let behind = parts
|
||||
.get(1)
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(0);
|
||||
Ok((ahead, behind))
|
||||
}
|
||||
|
||||
fn push_with_tracking(
|
||||
vault: &Path,
|
||||
connection: &RemoteConnection,
|
||||
success_message: String,
|
||||
) -> GitAddRemoteResult {
|
||||
match run_git(
|
||||
vault,
|
||||
&[
|
||||
"push",
|
||||
"-u",
|
||||
DEFAULT_REMOTE_NAME,
|
||||
connection.branch.as_str(),
|
||||
],
|
||||
) {
|
||||
Ok(()) => connect_result(ConnectStatus::Connected, success_message),
|
||||
Err(stderr) => classify_connect_error(&stderr),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_connect_error(stderr: &str) -> GitAddRemoteResult {
|
||||
let lower = stderr.to_lowercase();
|
||||
|
||||
if is_auth_error(&lower) {
|
||||
return connect_result(
|
||||
ConnectStatus::AuthError,
|
||||
"Could not connect to that remote because git reported an authentication error. Check your credentials and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
if is_network_error(&lower) {
|
||||
return connect_result(
|
||||
ConnectStatus::NetworkError,
|
||||
"Could not reach that remote. Check your connection and repository URL, then try again.",
|
||||
);
|
||||
}
|
||||
|
||||
connect_result(
|
||||
ConnectStatus::Error,
|
||||
format!(
|
||||
"Could not connect that remote: {}",
|
||||
concise_git_detail(stderr)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn command_error(command: &str, output: &Output) -> String {
|
||||
format!("{command} failed: {}", stderr_text(output))
|
||||
}
|
||||
|
||||
fn is_auth_error(lower: &str) -> bool {
|
||||
[
|
||||
"authentication failed",
|
||||
"could not read username",
|
||||
"permission denied",
|
||||
"the requested url returned error: 403",
|
||||
"invalid credentials",
|
||||
"repository not found",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| lower.contains(needle))
|
||||
}
|
||||
|
||||
fn is_network_error(lower: &str) -> bool {
|
||||
[
|
||||
"could not resolve host",
|
||||
"unable to access",
|
||||
"connection refused",
|
||||
"network is unreachable",
|
||||
"timed out",
|
||||
"couldn't connect",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| lower.contains(needle))
|
||||
}
|
||||
|
||||
fn concise_git_detail(stderr: &str) -> String {
|
||||
stderr
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.unwrap_or("git reported an unknown error")
|
||||
.trim_start_matches("fatal:")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::tests::{setup_git_repo, GitConfigEnvGuard};
|
||||
use crate::git::{git_commit, git_remote_status};
|
||||
use std::fs;
|
||||
use std::process::Command as StdCommand;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn init_bare_remote(path: &Path) {
|
||||
StdCommand::new("git")
|
||||
.args(["init", "--bare", "--initial-branch=main"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn configure_author(path: &Path, email: &str, name: &str) {
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", email])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", name])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn seed_remote_history(bare_path: &Path) {
|
||||
let working = TempDir::new().unwrap();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["clone", bare_path.to_str().unwrap(), "."])
|
||||
.current_dir(working.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
configure_author(working.path(), "remote@test.com", "Remote User");
|
||||
fs::write(working.path().join("remote.md"), "# Remote\n").unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(working.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["commit", "-m", "Seed remote"])
|
||||
.current_dir(working.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["push", "origin", "main"])
|
||||
.current_dir(working.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn create_local_commit(path: &Path, filename: &str, title: &str, message: &str) {
|
||||
fs::write(path.join(filename), format!("# {title}\n")).unwrap();
|
||||
git_commit(path.to_str().unwrap(), message).unwrap();
|
||||
}
|
||||
|
||||
fn clear_local_author(path: &Path) {
|
||||
for key in ["user.name", "user.email"] {
|
||||
StdCommand::new("git")
|
||||
.args(["config", "--local", "--unset-all", key])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn local_author_is_configured(path: &Path) -> bool {
|
||||
["user.name", "user.email"].into_iter().all(|key| {
|
||||
let output = StdCommand::new("git")
|
||||
.args(["config", "--local", key])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_all_remotes_removes_every_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vault_path = vault.to_str().unwrap();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["remote", "add", "origin", "https://example.com/one.git"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["remote", "add", "backup", "https://example.com/two.git"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
disconnect_all_remotes(vault_path).unwrap();
|
||||
|
||||
assert!(list_remotes(vault).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_add_remote_connects_an_empty_remote_and_pushes_local_history() {
|
||||
let local = setup_git_repo();
|
||||
configure_author(local.path(), "local@test.com", "Local User");
|
||||
create_local_commit(local.path(), "note.md", "Local", "Initial local commit");
|
||||
|
||||
let bare = TempDir::new().unwrap();
|
||||
init_bare_remote(bare.path());
|
||||
|
||||
let result = git_add_remote(
|
||||
local.path().to_str().unwrap(),
|
||||
bare.path().to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.status, "connected");
|
||||
assert!(result.message.contains("tracking"));
|
||||
|
||||
let status = git_remote_status(local.path().to_str().unwrap()).unwrap();
|
||||
assert!(status.has_remote);
|
||||
assert_eq!((status.ahead, status.behind), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_add_remote_sets_local_identity_when_existing_repo_has_none() {
|
||||
let _env = GitConfigEnvGuard::isolated();
|
||||
|
||||
let local = setup_git_repo();
|
||||
create_local_commit(local.path(), "note.md", "Local", "Initial local commit");
|
||||
clear_local_author(local.path());
|
||||
assert!(!local_author_is_configured(local.path()));
|
||||
|
||||
let bare = TempDir::new().unwrap();
|
||||
init_bare_remote(bare.path());
|
||||
|
||||
let result = git_add_remote(
|
||||
local.path().to_str().unwrap(),
|
||||
bare.path().to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.status, "connected");
|
||||
assert!(local_author_is_configured(local.path()));
|
||||
|
||||
let email = StdCommand::new("git")
|
||||
.args(["config", "--local", "user.email"])
|
||||
.current_dir(local.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&email.stdout).trim(),
|
||||
"vault@tolaria.default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_add_remote_pushes_when_remote_is_the_local_branch_ancestor() {
|
||||
let local = setup_git_repo();
|
||||
configure_author(local.path(), "local@test.com", "Local User");
|
||||
create_local_commit(local.path(), "note.md", "Base", "Base commit");
|
||||
|
||||
let bare = TempDir::new().unwrap();
|
||||
StdCommand::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--bare",
|
||||
local.path().to_str().unwrap(),
|
||||
bare.path().to_str().unwrap(),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_local_commit(local.path(), "next.md", "Next", "Local follow-up");
|
||||
|
||||
let result = git_add_remote(
|
||||
local.path().to_str().unwrap(),
|
||||
bare.path().to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.status, "connected");
|
||||
|
||||
let status = git_remote_status(local.path().to_str().unwrap()).unwrap();
|
||||
assert!(status.has_remote);
|
||||
assert_eq!((status.ahead, status.behind), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_add_remote_rejects_unrelated_remote_history_and_cleans_up() {
|
||||
let local = setup_git_repo();
|
||||
configure_author(local.path(), "local@test.com", "Local User");
|
||||
create_local_commit(local.path(), "note.md", "Local", "Local commit");
|
||||
|
||||
let bare = TempDir::new().unwrap();
|
||||
init_bare_remote(bare.path());
|
||||
seed_remote_history(bare.path());
|
||||
|
||||
let result = git_add_remote(
|
||||
local.path().to_str().unwrap(),
|
||||
bare.path().to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.status, "incompatible_history");
|
||||
assert!(result.message.contains("unrelated history"));
|
||||
assert!(list_remotes(local.path()).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_add_remote_reports_when_the_vault_is_already_remote_backed() {
|
||||
let local = setup_git_repo();
|
||||
let vault = local.path();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["remote", "add", "origin", "https://example.com/repo.git"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let result =
|
||||
git_add_remote(vault.to_str().unwrap(), "https://example.com/other.git").unwrap();
|
||||
|
||||
assert_eq!(result.status, "already_configured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_connect_error_maps_auth_failures() {
|
||||
let result = classify_connect_error(
|
||||
"fatal: unable to access 'https://github.com/org/repo.git/': The requested URL returned error: 403",
|
||||
);
|
||||
|
||||
assert_eq!(result.status, "auth_error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_connect_error_maps_network_failures() {
|
||||
let result = classify_connect_error(
|
||||
"fatal: unable to access 'https://github.com/org/repo.git/': Could not resolve host: github.com",
|
||||
);
|
||||
|
||||
assert_eq!(result.status, "network_error");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::io::Write;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Stdio;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::git_command_at;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn request_remote_credentials(vault: &Path, remote_url: &str) {
|
||||
let Some(input) = credential_fill_input(remote_url) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut child = match git_command_at(vault).and_then(|mut command| {
|
||||
command
|
||||
.args(["credential", "fill"])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
}) {
|
||||
Ok(child) => child,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = stdin.write_all(input.as_bytes());
|
||||
}
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(super) fn request_remote_credentials(_vault: &Path, _remote_url: &str) {}
|
||||
|
||||
#[cfg(any(test, target_os = "macos"))]
|
||||
struct CredentialTarget<'a> {
|
||||
protocol: &'a str,
|
||||
host: &'a str,
|
||||
username: Option<&'a str>,
|
||||
path: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "macos"))]
|
||||
fn credential_fill_input(remote_url: &str) -> Option<String> {
|
||||
let target = credential_target(remote_url)?;
|
||||
let mut lines = vec![
|
||||
format!("protocol={}", target.protocol),
|
||||
format!("host={}", target.host),
|
||||
];
|
||||
|
||||
if let Some(username) = target.username {
|
||||
lines.push(format!("username={username}"));
|
||||
}
|
||||
|
||||
if let Some(path) = target.path {
|
||||
lines.push(format!("path={path}"));
|
||||
}
|
||||
|
||||
Some(format!("{}\n\n", lines.join("\n")))
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "macos"))]
|
||||
fn credential_target(remote_url: &str) -> Option<CredentialTarget<'_>> {
|
||||
let (protocol, rest) = remote_url.trim().split_once("://")?;
|
||||
if !matches!(protocol, "https" | "http") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rest = rest.split_once('#').map_or(rest, |(value, _)| value);
|
||||
let rest = rest.split_once('?').map_or(rest, |(value, _)| value);
|
||||
let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
|
||||
if authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (username, host) = match authority.rsplit_once('@') {
|
||||
Some((userinfo, host)) => {
|
||||
let username = userinfo
|
||||
.split_once(':')
|
||||
.map_or(userinfo, |(name, _)| name)
|
||||
.trim();
|
||||
let username = (!username.is_empty()).then_some(username);
|
||||
(username, host)
|
||||
}
|
||||
None => (None, authority),
|
||||
};
|
||||
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CredentialTarget {
|
||||
protocol,
|
||||
host,
|
||||
username,
|
||||
path: (!path.is_empty()).then_some(path),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn credential_fill_input_extracts_https_remote_parts() {
|
||||
let input = credential_fill_input("https://github.com/refactoringhq/tolaria.git").unwrap();
|
||||
|
||||
assert!(input.contains("protocol=https\n"));
|
||||
assert!(input.contains("host=github.com\n"));
|
||||
assert!(input.contains("path=refactoringhq/tolaria.git\n"));
|
||||
assert!(input.ends_with("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_fill_input_ignores_ssh_remotes() {
|
||||
assert!(credential_fill_input("git@github.com:refactoringhq/tolaria.git").is_none());
|
||||
}
|
||||
}
|
||||
230
product-source/hololake-platform/src-tauri/src/git/dates.rs
Normal file
230
product-source/hololake-platform/src-tauri/src/git/dates.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
use super::git_command_at;
|
||||
use chrono::DateTime;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Git-derived creation and modification timestamps for a file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitDates {
|
||||
pub created_at: u64,
|
||||
pub modified_at: u64,
|
||||
}
|
||||
|
||||
/// Run a single `git log` to collect creation and modification dates for all
|
||||
/// tracked files in the repository. Returns a map from relative path to dates.
|
||||
///
|
||||
/// - **modified_at** = author date of the most recent commit touching the file
|
||||
/// - **created_at** = author date of the oldest commit touching the file
|
||||
///
|
||||
/// Files not yet committed (untracked / only staged) will not appear in the map;
|
||||
/// callers should fall back to filesystem metadata for those.
|
||||
pub fn get_all_file_dates(vault_path: &Path) -> HashMap<String, GitDates> {
|
||||
let output = match git_command_at(vault_path).and_then(|mut command| {
|
||||
command
|
||||
.args(["log", "--format=COMMIT %aI", "--name-only"])
|
||||
.output()
|
||||
}) {
|
||||
Ok(o) if o.status.success() => o,
|
||||
_ => return HashMap::new(),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_git_log_output(&stdout)
|
||||
}
|
||||
|
||||
/// Parse the output of `git log --format="COMMIT %aI" --name-only`.
|
||||
///
|
||||
/// Output looks like:
|
||||
/// ```text
|
||||
/// COMMIT 2026-03-15T10:00:00+02:00
|
||||
///
|
||||
/// file-a.md
|
||||
/// file-b.md
|
||||
///
|
||||
/// COMMIT 2026-03-10T08:00:00+02:00
|
||||
///
|
||||
/// file-a.md
|
||||
/// ```
|
||||
///
|
||||
/// Commits are ordered newest-first. For each file:
|
||||
/// - First occurrence → sets `modified_at`
|
||||
/// - Every subsequent occurrence overwrites `created_at` (last one = oldest commit wins)
|
||||
fn parse_git_log_output(stdout: &str) -> HashMap<String, GitDates> {
|
||||
let mut map: HashMap<String, GitDates> = HashMap::new();
|
||||
let mut current_ts: Option<u64> = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
if let Some(date_str) = line.strip_prefix("COMMIT ") {
|
||||
current_ts = parse_author_date(date_str);
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = line.trim();
|
||||
if path.is_empty() || current_ts.is_none() {
|
||||
continue;
|
||||
}
|
||||
// Only process .md files
|
||||
if !path.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = current_ts.unwrap();
|
||||
map.entry(path.to_string())
|
||||
.and_modify(|d| d.created_at = ts)
|
||||
.or_insert(GitDates {
|
||||
created_at: ts,
|
||||
modified_at: ts,
|
||||
});
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
fn parse_author_date(s: &str) -> Option<u64> {
|
||||
DateTime::parse_from_rfc3339(s.trim())
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp() as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_log_single_commit() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
file-a.md
|
||||
file-b.md
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert_eq!(map["file-a.md"].created_at, 1773568800);
|
||||
assert_eq!(map["file-a.md"].modified_at, 1773568800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_log_multiple_commits() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
file-a.md
|
||||
|
||||
COMMIT 2026-03-10T08:00:00+00:00
|
||||
|
||||
file-a.md
|
||||
file-b.md
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 2);
|
||||
// file-a: modified = newest (2026-03-15), created = oldest (2026-03-10)
|
||||
assert_eq!(map["file-a.md"].modified_at, 1773568800);
|
||||
assert_eq!(map["file-a.md"].created_at, 1773129600);
|
||||
// file-b: only in second commit
|
||||
assert_eq!(map["file-b.md"].modified_at, 1773129600);
|
||||
assert_eq!(map["file-b.md"].created_at, 1773129600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_md_files_filtered_out() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
README.txt
|
||||
note.md
|
||||
image.png
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 1);
|
||||
assert!(map.contains_key("note.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_output() {
|
||||
let map = parse_git_log_output("");
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subdirectory_paths() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
docs/adr/0001-stack.md
|
||||
notes/daily.md
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert!(map.contains_key("docs/adr/0001-stack.md"));
|
||||
assert!(map.contains_key("notes/daily.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_file_dates_in_real_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
// Init repo
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// First commit with one file
|
||||
std::fs::write(vault.join("first.md"), "# First\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "first"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Second commit with another file + modify first
|
||||
std::fs::write(vault.join("first.md"), "# First\nUpdated.\n").unwrap();
|
||||
std::fs::write(vault.join("second.md"), "# Second\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "second"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let map = get_all_file_dates(vault);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert!(map.contains_key("first.md"));
|
||||
assert!(map.contains_key("second.md"));
|
||||
|
||||
// first.md: created in commit 1, modified in commit 2
|
||||
// So modified_at > created_at (or equal if commits are same second)
|
||||
assert!(map["first.md"].modified_at >= map["first.md"].created_at);
|
||||
// second.md: only in commit 2
|
||||
assert_eq!(map["second.md"].modified_at, map["second.md"].created_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_file_dates_no_git_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let map = get_all_file_dates(dir.path());
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
}
|
||||
403
product-source/hololake-platform/src-tauri/src/git/file_url.rs
Normal file
403
product-source/hololake-platform/src-tauri/src/git/file_url.rs
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
use std::path::Path;
|
||||
|
||||
use crate::vault::path_identity::vault_relative_path_string;
|
||||
|
||||
use super::command::{git_output, stderr_or_failure, stdout_text};
|
||||
use super::remote_config::primary_remote_url;
|
||||
|
||||
enum RemoteWebKind {
|
||||
Bitbucket,
|
||||
Gitea,
|
||||
GitLab,
|
||||
Generic,
|
||||
}
|
||||
|
||||
struct RemoteWebBase {
|
||||
base_url: String,
|
||||
kind: RemoteWebKind,
|
||||
}
|
||||
|
||||
struct GitFileLocation {
|
||||
branch: BranchName,
|
||||
relative_path: RelativeGitPath,
|
||||
}
|
||||
|
||||
struct BranchName(String);
|
||||
|
||||
struct RelativeGitPath(String);
|
||||
|
||||
struct RemoteUrl(String);
|
||||
|
||||
struct RemoteParts {
|
||||
host: RemoteHost,
|
||||
repo_path: RepoPath,
|
||||
}
|
||||
|
||||
struct RemoteHost(String);
|
||||
|
||||
struct RepoPath(String);
|
||||
|
||||
pub fn git_file_url(vault_path: &str, file_path: &str) -> Result<Option<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
let Some(relative_path) = RelativeGitPath::from_paths(vault, file)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(remote_url) = primary_remote_url(vault)?.map(RemoteUrl::new) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let location = GitFileLocation::new(current_ref_name(vault)?, relative_path);
|
||||
|
||||
let url = match remote_web_base(&remote_url) {
|
||||
Some(remote) => remote.file_url(&location),
|
||||
None => remote_url.git_fragment_url(&location),
|
||||
};
|
||||
Ok(Some(url))
|
||||
}
|
||||
|
||||
fn current_ref_name(vault: &Path) -> Result<BranchName, String> {
|
||||
let output = git_output(vault, &["branch", "--show-current"])
|
||||
.map_err(|e| format!("Failed to get branch: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(stderr_or_failure("git branch", &output));
|
||||
}
|
||||
|
||||
let branch = stdout_text(&output);
|
||||
Ok(BranchName::new(branch))
|
||||
}
|
||||
|
||||
impl GitFileLocation {
|
||||
fn new(branch: BranchName, relative_path: RelativeGitPath) -> Self {
|
||||
Self {
|
||||
branch,
|
||||
relative_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BranchName {
|
||||
fn new(value: String) -> Self {
|
||||
if value.is_empty() {
|
||||
return Self("HEAD".to_string());
|
||||
}
|
||||
Self(value)
|
||||
}
|
||||
|
||||
fn encoded_fragment(&self) -> String {
|
||||
encode_fragment_part(&self.0)
|
||||
}
|
||||
|
||||
fn encoded_path(&self) -> String {
|
||||
encode_path(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl RelativeGitPath {
|
||||
fn from_paths(vault: &Path, file: &Path) -> Result<Option<Self>, String> {
|
||||
let value = vault_relative_path_string(vault, file)?;
|
||||
if value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(Self(value)))
|
||||
}
|
||||
|
||||
fn encoded_fragment(&self) -> String {
|
||||
encode_fragment_part(&self.0)
|
||||
}
|
||||
|
||||
fn encoded_path(&self) -> String {
|
||||
encode_path(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteWebBase {
|
||||
fn file_url(&self, location: &GitFileLocation) -> String {
|
||||
let branch = location.branch.encoded_path();
|
||||
let path = location.relative_path.encoded_path();
|
||||
match self.kind {
|
||||
RemoteWebKind::Bitbucket => format!("{}/src/{}/{}", self.base_url, branch, path),
|
||||
RemoteWebKind::Gitea => format!("{}/src/branch/{}/{}", self.base_url, branch, path),
|
||||
RemoteWebKind::GitLab => format!("{}/-/blob/{}/{}", self.base_url, branch, path),
|
||||
RemoteWebKind::Generic => format!("{}/blob/{}/{}", self.base_url, branch, path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteUrl {
|
||||
fn new(value: String) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
fn trimmed(&self) -> &str {
|
||||
self.0.trim()
|
||||
}
|
||||
|
||||
fn git_fragment_url(&self, location: &GitFileLocation) -> String {
|
||||
format!(
|
||||
"{}#{}:{}",
|
||||
self.trimmed(),
|
||||
location.branch.encoded_fragment(),
|
||||
location.relative_path.encoded_fragment(),
|
||||
)
|
||||
}
|
||||
|
||||
fn host_and_path(&self) -> Option<RemoteParts> {
|
||||
self.http_parts()
|
||||
.or_else(|| self.scheme_parts())
|
||||
.or_else(|| self.scp_parts())
|
||||
}
|
||||
|
||||
fn http_parts(&self) -> Option<RemoteParts> {
|
||||
let rest = self
|
||||
.trimmed()
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| self.trimmed().strip_prefix("http://"))?;
|
||||
RemoteParts::from_authority_path(rest)
|
||||
}
|
||||
|
||||
fn scheme_parts(&self) -> Option<RemoteParts> {
|
||||
let rest = self
|
||||
.trimmed()
|
||||
.strip_prefix("ssh://")
|
||||
.or_else(|| self.trimmed().strip_prefix("git://"))?;
|
||||
RemoteParts::from_authority_path(rest)
|
||||
}
|
||||
|
||||
fn scp_parts(&self) -> Option<RemoteParts> {
|
||||
let (_, target) = self.trimmed().split_once('@')?;
|
||||
let (host, path) = target.split_once(':')?;
|
||||
Some(RemoteParts::new(RemoteHost::new(host), RepoPath::new(path)))
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteParts {
|
||||
fn new(host: RemoteHost, repo_path: RepoPath) -> Self {
|
||||
Self { host, repo_path }
|
||||
}
|
||||
|
||||
fn from_authority_path(value: &str) -> Option<Self> {
|
||||
let (authority, path) = value.split_once('/')?;
|
||||
Some(Self::new(
|
||||
RemoteHost::from_authority(authority),
|
||||
RepoPath::new(path),
|
||||
))
|
||||
}
|
||||
|
||||
fn into_web_base(self) -> Option<RemoteWebBase> {
|
||||
let clean_path = self.repo_path.clean();
|
||||
if self.host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if clean_path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base_url = format!("https://{}/{clean_path}", self.host.as_str());
|
||||
Some(RemoteWebBase {
|
||||
kind: remote_web_kind(&self.host),
|
||||
base_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteHost {
|
||||
fn new(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
|
||||
fn from_authority(authority: &str) -> Self {
|
||||
Self::new(authority.rsplit('@').next().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
fn lower(&self) -> String {
|
||||
self.0.to_ascii_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoPath {
|
||||
fn new(value: &str) -> Self {
|
||||
Self(value.to_string())
|
||||
}
|
||||
|
||||
fn clean(&self) -> &str {
|
||||
let trimmed = self.0.trim_matches('/');
|
||||
trimmed.strip_suffix(".git").unwrap_or(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_web_base(remote_url: &RemoteUrl) -> Option<RemoteWebBase> {
|
||||
remote_url.host_and_path()?.into_web_base()
|
||||
}
|
||||
|
||||
fn remote_web_kind(host: &RemoteHost) -> RemoteWebKind {
|
||||
let lower_host = host.lower();
|
||||
if lower_host.contains("gitlab") {
|
||||
return RemoteWebKind::GitLab;
|
||||
}
|
||||
if lower_host.contains("bitbucket") {
|
||||
return RemoteWebKind::Bitbucket;
|
||||
}
|
||||
if lower_host.contains("gitea") {
|
||||
return RemoteWebKind::Gitea;
|
||||
}
|
||||
if lower_host.contains("forgejo") {
|
||||
return RemoteWebKind::Gitea;
|
||||
}
|
||||
if lower_host == "codeberg.org" {
|
||||
return RemoteWebKind::Gitea;
|
||||
}
|
||||
RemoteWebKind::Generic
|
||||
}
|
||||
|
||||
fn encode_path(path: &str) -> String {
|
||||
path.split('/')
|
||||
.map(encode_segment)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
fn encode_fragment_part(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b' ' => encoded.push_str("%20"),
|
||||
b'#' => encoded.push_str("%23"),
|
||||
b'%' => encoded.push_str("%25"),
|
||||
_ => encoded.push(char::from(byte)),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn encode_segment(segment: &str) -> String {
|
||||
segment
|
||||
.bytes()
|
||||
.flat_map(|byte| {
|
||||
if is_unreserved_url_byte(byte) {
|
||||
vec![byte]
|
||||
} else {
|
||||
format!("%{byte:02X}").into_bytes()
|
||||
}
|
||||
})
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_unreserved_url_byte(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_command;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
struct RemoteFixture {
|
||||
name: &'static str,
|
||||
url: &'static str,
|
||||
}
|
||||
|
||||
struct GitUrlCase {
|
||||
remote: RemoteFixture,
|
||||
note_path: &'static str,
|
||||
expected_url: &'static str,
|
||||
}
|
||||
|
||||
fn add_remote(vault: &Path, remote: RemoteFixture) {
|
||||
git_command()
|
||||
.args(["remote", "add", remote.name, remote.url])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_note(vault: &Path, relative_path: &str) -> String {
|
||||
let file = vault.join(relative_path);
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
fs::write(&file, "# Note\n").unwrap();
|
||||
file.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn assert_git_file_url(test_case: GitUrlCase) {
|
||||
let dir = setup_git_repo();
|
||||
let note = write_note(dir.path(), test_case.note_path);
|
||||
add_remote(dir.path(), test_case.remote);
|
||||
|
||||
let url = git_file_url(dir.path().to_str().unwrap(), ¬e).unwrap();
|
||||
|
||||
assert_eq!(url.as_deref(), Some(test_case.expected_url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_without_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let note = write_note(dir.path(), "note.md");
|
||||
|
||||
let url = git_file_url(dir.path().to_str().unwrap(), ¬e).unwrap();
|
||||
|
||||
assert_eq!(url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_outside_git_repository() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let note = write_note(dir.path(), "note.md");
|
||||
|
||||
let url = git_file_url(dir.path().to_str().unwrap(), ¬e).unwrap();
|
||||
|
||||
assert_eq!(url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_remote_note_urls() {
|
||||
[
|
||||
GitUrlCase {
|
||||
remote: RemoteFixture {
|
||||
name: "origin",
|
||||
url: "git@github.com:owner/repo.git",
|
||||
},
|
||||
note_path: "Notes/Project Plan.md",
|
||||
expected_url: "https://github.com/owner/repo/blob/main/Notes/Project%20Plan.md",
|
||||
},
|
||||
GitUrlCase {
|
||||
remote: RemoteFixture {
|
||||
name: "origin",
|
||||
url: "https://gho_secret@github.com/owner/repo.git",
|
||||
},
|
||||
note_path: "private.md",
|
||||
expected_url: "https://github.com/owner/repo/blob/main/private.md",
|
||||
},
|
||||
GitUrlCase {
|
||||
remote: RemoteFixture {
|
||||
name: "origin",
|
||||
url: "https://gitlab.com/group/repo.git",
|
||||
},
|
||||
note_path: "notes/topic.md",
|
||||
expected_url: "https://gitlab.com/group/repo/-/blob/main/notes/topic.md",
|
||||
},
|
||||
GitUrlCase {
|
||||
remote: RemoteFixture {
|
||||
name: "upstream",
|
||||
url: "https://github.com/team/vault.git",
|
||||
},
|
||||
note_path: "shared.md",
|
||||
expected_url: "https://github.com/team/vault/blob/main/shared.md",
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.for_each(assert_git_file_url);
|
||||
}
|
||||
}
|
||||
349
product-source/hololake-platform/src-tauri/src/git/history.rs
Normal file
349
product-source/hololake-platform/src-tauri/src/git/history.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
use super::git_command_at;
|
||||
use crate::vault::path_identity::vault_relative_path_string;
|
||||
use std::path::Path;
|
||||
|
||||
use super::GitCommit;
|
||||
|
||||
/// Get git log history for a specific file in the vault.
|
||||
pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitCommit>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
let relative_str = vault_relative_path_string(vault, file)?;
|
||||
|
||||
let output = git_command_at(vault)
|
||||
.and_then(|mut command| {
|
||||
command
|
||||
.args([
|
||||
"log",
|
||||
"--format=%H|%h|%an|%aI|%s",
|
||||
"-n",
|
||||
"20",
|
||||
"--",
|
||||
&relative_str,
|
||||
])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// No commits yet is not an error - just return empty history
|
||||
if stderr.contains("does not have any commits yet") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return Err(format!("git log failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let commits = stdout
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.filter_map(|line| {
|
||||
// Format: hash|short_hash|author|date|message
|
||||
// Use splitn(5) so message (last) can contain '|'
|
||||
let parts: Vec<&str> = line.splitn(5, '|').collect();
|
||||
if parts.len() != 5 {
|
||||
return None;
|
||||
}
|
||||
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
|
||||
Some(GitCommit {
|
||||
hash: parts[0].to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
author: parts[2].to_string(),
|
||||
date,
|
||||
message: parts[4].to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// Get git diff for a specific file.
|
||||
pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
let relative_str = vault_relative_path_string(vault, file)?;
|
||||
|
||||
// First try tracked file diff
|
||||
let output = git_command_at(vault)
|
||||
.and_then(|mut command| command.args(["diff", "--", &relative_str]).output())
|
||||
.map_err(|e| format!("Failed to run git diff: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
|
||||
// If no diff (maybe staged or untracked), try diff --cached
|
||||
if stdout.is_empty() {
|
||||
let cached = git_command_at(vault)
|
||||
.and_then(|mut command| {
|
||||
command
|
||||
.args(["diff", "--cached", "--", &relative_str])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| format!("Failed to run git diff --cached: {}", e))?;
|
||||
|
||||
let cached_stdout = String::from_utf8_lossy(&cached.stdout).to_string();
|
||||
if !cached_stdout.is_empty() {
|
||||
return Ok(cached_stdout);
|
||||
}
|
||||
|
||||
// Try showing untracked file as all-new
|
||||
let status = git_command_at(vault)
|
||||
.and_then(|mut command| {
|
||||
command
|
||||
.args(["status", "--porcelain", "--", &relative_str])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
||||
|
||||
let status_out = String::from_utf8_lossy(&status.stdout);
|
||||
if status_out.starts_with("??") {
|
||||
// Untracked file: show entire content as added
|
||||
let content =
|
||||
std::fs::read_to_string(file).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
|
||||
return Ok(format!(
|
||||
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
|
||||
relative_str,
|
||||
lines.len(),
|
||||
lines.join("\n")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stdout)
|
||||
}
|
||||
|
||||
/// Get git diff for a specific file at a given commit (compared to its parent).
|
||||
pub fn get_file_diff_at_commit(
|
||||
vault_path: &str,
|
||||
file_path: &str,
|
||||
commit_hash: &str,
|
||||
) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
let relative_str = vault_relative_path_string(vault, file)?;
|
||||
|
||||
// Show diff between commit^ and commit for this file
|
||||
let output = git_command_at(vault)
|
||||
.and_then(|mut command| {
|
||||
command
|
||||
.args([
|
||||
"diff",
|
||||
&format!("{}^", commit_hash),
|
||||
commit_hash,
|
||||
"--",
|
||||
&relative_str,
|
||||
])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| format!("Failed to run git diff: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
|
||||
// If diff is empty, it might be the initial commit (no parent).
|
||||
// Fall back to showing the full file content as added.
|
||||
if stdout.is_empty() {
|
||||
let show = git_command_at(vault)
|
||||
.and_then(|mut command| {
|
||||
command
|
||||
.args(["show", &format!("{}:{}", commit_hash, relative_str)])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| format!("Failed to run git show: {}", e))?;
|
||||
|
||||
if show.status.success() {
|
||||
let content = String::from_utf8_lossy(&show.stdout);
|
||||
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
|
||||
return Ok(format!(
|
||||
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
|
||||
relative_str,
|
||||
lines.len(),
|
||||
lines.join("\n")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stdout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_command;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
fn force_quoted_git_paths(vault: &Path) {
|
||||
git_command()
|
||||
.args(["config", "core.quotePath", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_and_commit_file(
|
||||
vault: &Path,
|
||||
relative_path: &str,
|
||||
content: &str,
|
||||
message: &str,
|
||||
) -> PathBuf {
|
||||
let file = vault.join(relative_path);
|
||||
fs::write(&file, content).unwrap();
|
||||
git_command()
|
||||
.args(["add", relative_path])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", message])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
file
|
||||
}
|
||||
|
||||
fn head_hash(vault: &Path) -> String {
|
||||
let log = git_command()
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&log.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_history_with_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
let file = write_and_commit_file(vault, "test.md", "# Initial\n", "Initial commit");
|
||||
write_and_commit_file(vault, "test.md", "# Updated\n\nNew content.", "Update test");
|
||||
|
||||
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history[0].message, "Update test");
|
||||
assert_eq!(history[1].message, "Initial commit");
|
||||
assert_eq!(history[0].author, "Test User");
|
||||
assert!(!history[0].hash.is_empty());
|
||||
assert!(!history[0].short_hash.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_history_no_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("new.md");
|
||||
fs::write(&file, "# New\n").unwrap();
|
||||
|
||||
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
let file = write_and_commit_file(
|
||||
vault,
|
||||
"diff-test.md",
|
||||
"# Test\n\nOriginal content.",
|
||||
"Add diff-test",
|
||||
);
|
||||
|
||||
fs::write(&file, "# Test\n\nModified content.").unwrap();
|
||||
|
||||
let diff = get_file_diff(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("-Original content."));
|
||||
assert!(diff.contains("+Modified content."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff_at_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
let file = write_and_commit_file(
|
||||
vault,
|
||||
"diff-at-commit.md",
|
||||
"# First\n\nOriginal content.",
|
||||
"First commit",
|
||||
);
|
||||
write_and_commit_file(
|
||||
vault,
|
||||
"diff-at-commit.md",
|
||||
"# First\n\nModified content.",
|
||||
"Second commit",
|
||||
);
|
||||
|
||||
let hash = head_hash(vault);
|
||||
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("-Original content."));
|
||||
assert!(diff.contains("+Modified content."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff_at_initial_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
let file = write_and_commit_file(
|
||||
vault,
|
||||
"initial.md",
|
||||
"# Initial\n\nHello world.",
|
||||
"Initial commit",
|
||||
);
|
||||
|
||||
let hash = head_hash(vault);
|
||||
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("+# Initial"));
|
||||
assert!(diff.contains("+Hello world."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff_at_commit_preserves_chinese_filename_and_content() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let relative_path = "中文笔记.md";
|
||||
let file = vault.join(relative_path);
|
||||
|
||||
force_quoted_git_paths(vault);
|
||||
write_and_commit_file(
|
||||
vault,
|
||||
relative_path,
|
||||
"# 初始\n\n第一行\n",
|
||||
"Add Chinese note",
|
||||
);
|
||||
write_and_commit_file(
|
||||
vault,
|
||||
relative_path,
|
||||
"# 初始\n\n第二行\n",
|
||||
"Update Chinese note",
|
||||
);
|
||||
let hash = head_hash(vault);
|
||||
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
|
||||
assert!(diff.contains("diff --git a/中文笔记.md b/中文笔记.md"));
|
||||
assert!(diff.contains("-第一行"));
|
||||
assert!(diff.contains("+第二行"));
|
||||
assert!(!diff.contains("\\344"));
|
||||
}
|
||||
}
|
||||
1150
product-source/hololake-platform/src-tauri/src/git/mod.rs
Normal file
1150
product-source/hololake-platform/src-tauri/src/git/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
443
product-source/hololake-platform/src-tauri/src/git/provider.rs
Normal file
443
product-source/hololake-platform/src-tauri/src/git/provider.rs
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
use serde::Serialize;
|
||||
use std::ffi::OsString;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use crate::settings::{normalize_git_provider, Settings};
|
||||
|
||||
pub(super) const NATIVE_PROVIDER: &str = "native";
|
||||
pub(super) const WSL_PROVIDER: &str = "wsl";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ProbeIdentity {
|
||||
provider: &'static str,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
const NATIVE_GIT_IDENTITY: ProbeIdentity = ProbeIdentity {
|
||||
provider: NATIVE_PROVIDER,
|
||||
label: "Native Git",
|
||||
};
|
||||
|
||||
const WSL_GIT_IDENTITY: ProbeIdentity = ProbeIdentity {
|
||||
provider: WSL_PROVIDER,
|
||||
label: "WSL2 Git",
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum GitProviderSelection {
|
||||
Native,
|
||||
Wsl { distro: Option<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitProviderProbe {
|
||||
pub provider: String,
|
||||
pub label: String,
|
||||
pub available: bool,
|
||||
pub version: Option<String>,
|
||||
pub distro: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitProviderStatus {
|
||||
pub selected_provider: String,
|
||||
pub selected_wsl_distro: Option<String>,
|
||||
pub native: GitProviderProbe,
|
||||
pub wsl_distributions: Vec<GitProviderProbe>,
|
||||
}
|
||||
|
||||
impl GitProviderSelection {
|
||||
pub(super) fn from_settings(settings: Option<&Settings>) -> Self {
|
||||
let provider =
|
||||
settings.and_then(|settings| normalize_git_provider(settings.git_provider.as_deref()));
|
||||
|
||||
if provider.as_deref() == Some(WSL_PROVIDER) && wsl_supported_on_this_platform() {
|
||||
return Self::Wsl {
|
||||
distro: settings.and_then(|settings| settings.git_wsl_distro.clone()),
|
||||
};
|
||||
}
|
||||
|
||||
Self::Native
|
||||
}
|
||||
|
||||
pub(super) fn provider_id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Native => NATIVE_PROVIDER,
|
||||
Self::Wsl { .. } => WSL_PROVIDER,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wsl_git_prefix_args(distro: Option<&str>) -> Vec<OsString> {
|
||||
let mut args = Vec::new();
|
||||
if let Some(distro) = distro.map(str::trim).filter(|distro| !distro.is_empty()) {
|
||||
args.push(OsString::from("--distribution"));
|
||||
args.push(OsString::from(distro));
|
||||
}
|
||||
args.push(OsString::from("--exec"));
|
||||
args.push(OsString::from("git"));
|
||||
args
|
||||
}
|
||||
|
||||
pub(super) fn selected_git_path_argument(
|
||||
path: &str,
|
||||
settings: Option<&Settings>,
|
||||
) -> Result<String, String> {
|
||||
match GitProviderSelection::from_settings(settings) {
|
||||
GitProviderSelection::Wsl { .. } => windows_path_to_wsl_path(path).ok_or_else(|| {
|
||||
format!("The selected WSL Git provider cannot translate '{path}' to a WSL path.")
|
||||
}),
|
||||
GitProviderSelection::Native => Ok(path.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn git_provider_status() -> GitProviderStatus {
|
||||
let settings = crate::settings::get_settings().ok();
|
||||
let selection = GitProviderSelection::from_settings(settings.as_ref());
|
||||
|
||||
GitProviderStatus {
|
||||
selected_provider: selection.provider_id().to_string(),
|
||||
selected_wsl_distro: settings.and_then(|settings| settings.git_wsl_distro),
|
||||
native: native_git_probe(),
|
||||
wsl_distributions: wsl_git_probes(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn test_git_provider(
|
||||
provider: &str,
|
||||
distro: Option<&str>,
|
||||
vault_path: Option<&str>,
|
||||
) -> GitProviderProbe {
|
||||
match normalize_git_provider(Some(provider)).as_deref() {
|
||||
Some(WSL_PROVIDER) => wsl_git_probe(distro, vault_path),
|
||||
_ => native_git_probe(),
|
||||
}
|
||||
}
|
||||
|
||||
fn native_git_probe() -> GitProviderProbe {
|
||||
let output = Command::new("git").arg("--version").output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => available_probe(
|
||||
NATIVE_GIT_IDENTITY,
|
||||
None,
|
||||
None,
|
||||
version_from_output(&output),
|
||||
),
|
||||
Ok(output) => unavailable_probe(NATIVE_GIT_IDENTITY, None, native_failure_message(&output)),
|
||||
Err(err) => unavailable_probe(
|
||||
NATIVE_GIT_IDENTITY,
|
||||
None,
|
||||
format!("Native Git is unavailable: {err}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn wsl_git_probes() -> Vec<GitProviderProbe> {
|
||||
match wsl_distribution_names() {
|
||||
Ok(distributions) if !distributions.is_empty() => distributions
|
||||
.into_iter()
|
||||
.map(|distro| wsl_git_probe(Some(&distro), None))
|
||||
.collect(),
|
||||
Ok(_) => vec![unavailable_probe(
|
||||
WSL_GIT_IDENTITY,
|
||||
None,
|
||||
"WSL is installed, but no distributions are configured.".to_string(),
|
||||
)],
|
||||
Err(message) => vec![unavailable_probe(WSL_GIT_IDENTITY, None, message)],
|
||||
}
|
||||
}
|
||||
|
||||
fn wsl_git_probe(distro: Option<&str>, vault_path: Option<&str>) -> GitProviderProbe {
|
||||
if !wsl_supported_on_this_platform() {
|
||||
return unavailable_probe(
|
||||
WSL_GIT_IDENTITY,
|
||||
distro.map(ToOwned::to_owned),
|
||||
"WSL2 Git is only available on Windows.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let translated_vault_path = match vault_path
|
||||
.and_then(|path| (!path.trim().is_empty()).then(|| windows_path_to_wsl_path(path)))
|
||||
{
|
||||
Some(Some(path)) => Some(path),
|
||||
Some(None) => {
|
||||
return unavailable_probe(
|
||||
WSL_GIT_IDENTITY,
|
||||
distro.map(ToOwned::to_owned),
|
||||
"The selected vault path cannot be translated to WSL.".to_string(),
|
||||
);
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let output = wsl_git_version_command(distro, translated_vault_path.as_deref()).output();
|
||||
match output {
|
||||
Ok(output) if output.status.success() => available_probe(
|
||||
WSL_GIT_IDENTITY,
|
||||
distro.map(ToOwned::to_owned),
|
||||
translated_vault_path,
|
||||
version_from_output(&output),
|
||||
),
|
||||
Ok(output) => unavailable_probe(
|
||||
WSL_GIT_IDENTITY,
|
||||
distro.map(ToOwned::to_owned),
|
||||
native_failure_message(&output),
|
||||
),
|
||||
Err(err) => unavailable_probe(
|
||||
WSL_GIT_IDENTITY,
|
||||
distro.map(ToOwned::to_owned),
|
||||
format!("WSL2 Git is unavailable: {err}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn available_probe(
|
||||
identity: ProbeIdentity,
|
||||
distro: Option<String>,
|
||||
path: Option<String>,
|
||||
version: Option<String>,
|
||||
) -> GitProviderProbe {
|
||||
let message = version
|
||||
.as_deref()
|
||||
.map(|version| format!("{} is available: {version}", identity.label))
|
||||
.unwrap_or_else(|| format!("{} is available.", identity.label));
|
||||
|
||||
GitProviderProbe {
|
||||
provider: identity.provider.to_string(),
|
||||
label: identity.label.to_string(),
|
||||
available: true,
|
||||
version,
|
||||
distro,
|
||||
path,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable_probe(
|
||||
identity: ProbeIdentity,
|
||||
distro: Option<String>,
|
||||
message: String,
|
||||
) -> GitProviderProbe {
|
||||
GitProviderProbe {
|
||||
provider: identity.provider.to_string(),
|
||||
label: identity.label.to_string(),
|
||||
available: false,
|
||||
version: None,
|
||||
distro,
|
||||
path: None,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
fn native_failure_message(output: &Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if !stderr.is_empty() {
|
||||
return stderr;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !stdout.is_empty() {
|
||||
return stdout;
|
||||
}
|
||||
|
||||
format!("Git exited with status {}", output.status)
|
||||
}
|
||||
|
||||
fn version_from_output(output: &Output) -> Option<String> {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_supported_on_this_platform() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn wsl_supported_on_this_platform() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_distribution_names() -> Result<Vec<String>, String> {
|
||||
let output = Command::new("wsl.exe")
|
||||
.args(["--list", "--quiet"])
|
||||
.output()
|
||||
.map_err(|err| format!("WSL is unavailable: {err}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(native_failure_message(&output));
|
||||
}
|
||||
|
||||
Ok(parse_wsl_distribution_names(&output.stdout))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn wsl_distribution_names() -> Result<Vec<String>, String> {
|
||||
Err("WSL2 Git is only available on Windows.".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_git_version_command(distro: Option<&str>, translated_vault_path: Option<&str>) -> Command {
|
||||
let mut command = Command::new("wsl.exe");
|
||||
if let Some(distro) = distro.map(str::trim).filter(|distro| !distro.is_empty()) {
|
||||
command.args(["--distribution", distro]);
|
||||
}
|
||||
if let Some(path) = translated_vault_path {
|
||||
command.args(["--cd", path]);
|
||||
}
|
||||
command.args(["--exec", "git", "--version"]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn wsl_git_version_command(_distro: Option<&str>, _translated_vault_path: Option<&str>) -> Command {
|
||||
Command::new("wsl.exe")
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn parse_wsl_distribution_names(output: &[u8]) -> Vec<String> {
|
||||
decode_wsl_output(output)
|
||||
.lines()
|
||||
.map(|line| line.trim().trim_end_matches('\r').to_string())
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn decode_wsl_output(output: &[u8]) -> String {
|
||||
if output.len() >= 2 && output.chunks_exact(2).any(|chunk| chunk[1] == 0) {
|
||||
let units = output
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
||||
.collect::<Vec<_>>();
|
||||
return String::from_utf16_lossy(&units).replace('\0', "");
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(output).replace('\0', "")
|
||||
}
|
||||
|
||||
fn windows_path_to_wsl_path(path: &str) -> Option<String> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if trimmed.starts_with('/') {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
|
||||
let normalized = trimmed.replace('\\', "/");
|
||||
if let Some(path) = drive_path_to_wsl_path(&normalized) {
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
wsl_unc_path_to_linux_path(&normalized)
|
||||
}
|
||||
|
||||
fn drive_path_to_wsl_path(path: &str) -> Option<String> {
|
||||
let bytes = path.as_bytes();
|
||||
if bytes.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
if bytes[1] != b':' {
|
||||
return None;
|
||||
}
|
||||
if bytes[2] != b'/' {
|
||||
return None;
|
||||
}
|
||||
|
||||
let drive = bytes[0] as char;
|
||||
if !drive.is_ascii_alphabetic() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"/mnt/{}/{}",
|
||||
drive.to_ascii_lowercase(),
|
||||
&path[3..]
|
||||
))
|
||||
}
|
||||
|
||||
fn wsl_unc_path_to_linux_path(path: &str) -> Option<String> {
|
||||
for prefix in ["//wsl$/", "//wsl.localhost/"] {
|
||||
if let Some(rest) = path.strip_prefix(prefix) {
|
||||
let (_, linux_path) = rest.split_once('/')?;
|
||||
return Some(format!("/{linux_path}"));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_utf8_wsl_distribution_names() {
|
||||
assert_eq!(
|
||||
parse_wsl_distribution_names(b"Ubuntu\r\nDebian\r\n"),
|
||||
vec!["Ubuntu", "Debian"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_utf16_wsl_distribution_names() {
|
||||
let encoded = "Ubuntu\r\nDebian\r\n"
|
||||
.encode_utf16()
|
||||
.flat_map(u16::to_le_bytes)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
parse_wsl_distribution_names(&encoded),
|
||||
vec!["Ubuntu", "Debian"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translates_windows_drive_paths_for_wsl() {
|
||||
assert_eq!(
|
||||
windows_path_to_wsl_path(r"C:\Users\Luca\Vault").as_deref(),
|
||||
Some("/mnt/c/Users/Luca/Vault")
|
||||
);
|
||||
assert_eq!(
|
||||
windows_path_to_wsl_path("D:/Work/Tolaria").as_deref(),
|
||||
Some("/mnt/d/Work/Tolaria")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translates_wsl_unc_paths_for_wsl() {
|
||||
assert_eq!(
|
||||
windows_path_to_wsl_path(r"\\wsl$\Ubuntu\home\luca\vault").as_deref(),
|
||||
Some("/home/luca/vault")
|
||||
);
|
||||
assert_eq!(
|
||||
windows_path_to_wsl_path(r"\\wsl.localhost\Debian\var\repo").as_deref(),
|
||||
Some("/var/repo")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_untranslatable_relative_paths() {
|
||||
assert_eq!(windows_path_to_wsl_path("notes/vault"), None);
|
||||
assert_eq!(windows_path_to_wsl_path(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_wsl_git_prefix_args() {
|
||||
assert_eq!(
|
||||
wsl_git_prefix_args(Some("Ubuntu"))
|
||||
.into_iter()
|
||||
.map(|arg| arg.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["--distribution", "Ubuntu", "--exec", "git"]
|
||||
);
|
||||
}
|
||||
}
|
||||
599
product-source/hololake-platform/src-tauri/src/git/pulse.rs
Normal file
599
product-source/hololake-platform/src-tauri/src/git/pulse.rs
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
use super::{git_command_at, parse_github_repo_path};
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct PulseFile {
|
||||
pub path: String,
|
||||
pub status: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct PulseCommit {
|
||||
pub hash: String,
|
||||
#[serde(rename = "shortHash")]
|
||||
pub short_hash: String,
|
||||
pub message: String,
|
||||
pub date: i64,
|
||||
#[serde(rename = "githubUrl")]
|
||||
pub github_url: Option<String>,
|
||||
pub files: Vec<PulseFile>,
|
||||
pub added: usize,
|
||||
pub modified: usize,
|
||||
pub deleted: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct LastCommitInfo {
|
||||
#[serde(rename = "shortHash")]
|
||||
pub short_hash: String,
|
||||
#[serde(rename = "commitUrl")]
|
||||
pub commit_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CommitHash<'a>(&'a str);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct GitLogLine<'a>(&'a str);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct GitLogOutput<'a>(&'a str);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct GitStatusCode<'a>(&'a str);
|
||||
|
||||
struct GitHubBaseUrl(String);
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum FileChangeStatus {
|
||||
Added,
|
||||
Modified,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct VaultRelativePath<'a>(&'a str);
|
||||
|
||||
impl<'a> CommitHash<'a> {
|
||||
fn as_str(self) -> &'a str {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FileChangeStatus {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
FileChangeStatus::Added => "added",
|
||||
FileChangeStatus::Modified => "modified",
|
||||
FileChangeStatus::Deleted => "deleted",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GitHubBaseUrl {
|
||||
fn commit_url(&self, hash: CommitHash<'_>) -> String {
|
||||
format!("{}/commit/{}", self.0, hash.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> GitLogLine<'a> {
|
||||
fn as_str(self) -> &'a str {
|
||||
self.0
|
||||
}
|
||||
|
||||
fn is_empty(self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> GitLogOutput<'a> {
|
||||
fn lines(self) -> impl Iterator<Item = GitLogLine<'a>> {
|
||||
self.0.lines().map(GitLogLine)
|
||||
}
|
||||
}
|
||||
|
||||
fn title_from_path(path: VaultRelativePath<'_>) -> String {
|
||||
path.0
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(path.0)
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(path.0)
|
||||
.replace('-', " ")
|
||||
}
|
||||
|
||||
fn parse_file_status(code: GitStatusCode<'_>) -> FileChangeStatus {
|
||||
match code.0 {
|
||||
"A" => FileChangeStatus::Added,
|
||||
"M" => FileChangeStatus::Modified,
|
||||
"D" => FileChangeStatus::Deleted,
|
||||
_ => FileChangeStatus::Modified,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
|
||||
/// `skip` offsets into the commit list for pagination; `limit` caps how many to return.
|
||||
pub fn get_vault_pulse(
|
||||
vault_path: impl AsRef<Path>,
|
||||
limit: usize,
|
||||
skip: usize,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault = vault_path.as_ref();
|
||||
|
||||
if !vault.join(".git").exists() {
|
||||
return Err("Not a git repository".to_string());
|
||||
}
|
||||
|
||||
let limit_str = limit.to_string();
|
||||
let skip_str = skip.to_string();
|
||||
let output = git_command_at(vault)
|
||||
.and_then(|mut command| {
|
||||
command
|
||||
.args([
|
||||
"log",
|
||||
"--name-status",
|
||||
"--pretty=format:%H|%h|%s|%aI",
|
||||
"--diff-filter=ADM",
|
||||
"-n",
|
||||
&limit_str,
|
||||
"--skip",
|
||||
&skip_str,
|
||||
"--",
|
||||
"*.md",
|
||||
])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("does not have any commits yet") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return Err(format!("git log failed: {}", stderr));
|
||||
}
|
||||
|
||||
let github_base = get_github_base_url(vault);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(parse_pulse_output(
|
||||
GitLogOutput(stdout.as_ref()),
|
||||
github_base.as_ref(),
|
||||
))
|
||||
}
|
||||
|
||||
fn get_github_base_url(vault: &Path) -> Option<GitHubBaseUrl> {
|
||||
let output = git_command_at(vault)
|
||||
.and_then(|mut command| command.args(["remote", "get-url", "origin"]).output())
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let repo_path = parse_github_repo_path(&url)?;
|
||||
Some(GitHubBaseUrl(format!("https://github.com/{}", repo_path)))
|
||||
}
|
||||
|
||||
fn parse_pulse_output(
|
||||
stdout: GitLogOutput<'_>,
|
||||
github_base: Option<&GitHubBaseUrl>,
|
||||
) -> Vec<PulseCommit> {
|
||||
let mut commits: Vec<PulseCommit> = Vec::new();
|
||||
let mut current: Option<PulseCommit> = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_commit_header(line) {
|
||||
push_current_commit(&mut commits, &mut current);
|
||||
current = parse_commit_header(line, github_base);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ref mut commit) = current {
|
||||
add_file_change(commit, line);
|
||||
}
|
||||
}
|
||||
|
||||
push_current_commit(&mut commits, &mut current);
|
||||
|
||||
commits
|
||||
}
|
||||
|
||||
fn is_git_status_line(line: GitLogLine<'_>) -> bool {
|
||||
let line = line.as_str();
|
||||
line.starts_with(|c: char| {
|
||||
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
|
||||
})
|
||||
}
|
||||
|
||||
fn is_commit_header(line: GitLogLine<'_>) -> bool {
|
||||
line.as_str().contains('|') && !is_git_status_line(line)
|
||||
}
|
||||
|
||||
fn push_current_commit(commits: &mut Vec<PulseCommit>, current: &mut Option<PulseCommit>) {
|
||||
if let Some(commit) = current.take() {
|
||||
commits.push(commit);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_commit_header(
|
||||
line: GitLogLine<'_>,
|
||||
github_base: Option<&GitHubBaseUrl>,
|
||||
) -> Option<PulseCommit> {
|
||||
let parts: Vec<&str> = line.as_str().splitn(4, '|').collect();
|
||||
if parts.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hash = CommitHash(parts[0]);
|
||||
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
let github_url = github_base.map(|base| base.commit_url(hash));
|
||||
|
||||
Some(PulseCommit {
|
||||
hash: hash.as_str().to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
message: parts[2].to_string(),
|
||||
date,
|
||||
github_url,
|
||||
files: Vec::new(),
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn add_file_change(commit: &mut PulseCommit, line: GitLogLine<'_>) {
|
||||
let file_parts: Vec<&str> = line.as_str().splitn(2, '\t').collect();
|
||||
if file_parts.len() != 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let status = parse_file_status(GitStatusCode(file_parts[0].trim()));
|
||||
let path = file_parts[1].trim();
|
||||
match status {
|
||||
FileChangeStatus::Added => commit.added += 1,
|
||||
FileChangeStatus::Deleted => commit.deleted += 1,
|
||||
_ => commit.modified += 1,
|
||||
}
|
||||
commit.files.push(PulseFile {
|
||||
path: path.to_string(),
|
||||
status: status.as_str().to_string(),
|
||||
title: title_from_path(VaultRelativePath(path)),
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the last commit's short hash and a GitHub URL (if remote is GitHub).
|
||||
pub fn get_last_commit_info(
|
||||
vault_path: impl AsRef<Path>,
|
||||
) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault = vault_path.as_ref();
|
||||
|
||||
let output = git_command_at(vault)
|
||||
.and_then(|mut command| command.args(["log", "-1", "--format=%H|%h"]).output())
|
||||
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("does not have any commits yet") {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(format!("git log failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let line = stdout.trim();
|
||||
if line.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.splitn(2, '|').collect();
|
||||
if parts.len() != 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let full_hash = parts[0];
|
||||
let short_hash = parts[1].to_string();
|
||||
|
||||
let commit_url = get_github_commit_url(vault, CommitHash(full_hash));
|
||||
|
||||
Ok(Some(LastCommitInfo {
|
||||
short_hash,
|
||||
commit_url,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Try to build a GitHub commit URL from the origin remote URL.
|
||||
fn get_github_commit_url(vault: &Path, full_hash: CommitHash<'_>) -> Option<String> {
|
||||
get_github_base_url(vault).map(|base| base.commit_url(full_hash))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum GitHubRemote {
|
||||
OwnerRepo,
|
||||
LaputaVault,
|
||||
}
|
||||
|
||||
enum NoteRepoChange {
|
||||
ConfigOnlyCommit,
|
||||
NoteUpdateCommit,
|
||||
}
|
||||
|
||||
enum CommitUrlSource {
|
||||
Pulse,
|
||||
LastCommitInfo,
|
||||
}
|
||||
|
||||
impl GitHubRemote {
|
||||
fn url(&self) -> &'static str {
|
||||
match self {
|
||||
GitHubRemote::OwnerRepo => "https://github.com/owner/repo.git",
|
||||
GitHubRemote::LaputaVault => "https://github.com/lucaong/laputa-vault.git",
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_prefix(&self) -> &'static str {
|
||||
match self {
|
||||
GitHubRemote::OwnerRepo => "https://github.com/owner/repo/commit/",
|
||||
GitHubRemote::LaputaVault => "https://github.com/lucaong/laputa-vault/commit/",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NoteRepoChange {
|
||||
fn apply(self, dir: &TempDir) {
|
||||
let vault = dir.path();
|
||||
let vp = vault_path(dir);
|
||||
|
||||
match self {
|
||||
NoteRepoChange::ConfigOnlyCommit => {
|
||||
fs::write(vault.join("config.json"), "{}").unwrap();
|
||||
git_commit(vp, "Add config").unwrap();
|
||||
}
|
||||
NoteRepoChange::NoteUpdateCommit => {
|
||||
fs::write(vault.join("note.md"), "# Updated\n").unwrap();
|
||||
git_commit(vp, "Update note").unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn vault_path(dir: &TempDir) -> &str {
|
||||
dir.path().to_str().unwrap()
|
||||
}
|
||||
|
||||
fn repo_with_committed_note() -> TempDir {
|
||||
let dir = setup_git_repo();
|
||||
|
||||
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vault_path(&dir), "Add note").unwrap();
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
fn add_origin_remote(vault: &Path, remote: GitHubRemote) {
|
||||
Command::new("git")
|
||||
.args(["remote", "add", "origin", remote.url()])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn pulse_after_note_repo_change(change: NoteRepoChange) -> Vec<PulseCommit> {
|
||||
let dir = repo_with_committed_note();
|
||||
change.apply(&dir);
|
||||
|
||||
get_vault_pulse(vault_path(&dir), 30, 0).unwrap()
|
||||
}
|
||||
|
||||
fn commit_url_for(source: CommitUrlSource, remote: GitHubRemote) -> String {
|
||||
let dir = repo_with_committed_note();
|
||||
add_origin_remote(dir.path(), remote);
|
||||
|
||||
match source {
|
||||
CommitUrlSource::Pulse => get_vault_pulse(vault_path(&dir), 30, 0).unwrap()[0]
|
||||
.github_url
|
||||
.clone()
|
||||
.unwrap(),
|
||||
CommitUrlSource::LastCommitInfo => get_last_commit_info(vault_path(&dir))
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.commit_url
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_with_commits() {
|
||||
let dir = repo_with_committed_note();
|
||||
let vault = dir.path();
|
||||
let vp = vault_path(&dir);
|
||||
|
||||
fs::write(vault.join("project.md"), "# Project\n").unwrap();
|
||||
git_commit(vp, "Add project").unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
||||
|
||||
assert_eq!(pulse.len(), 2);
|
||||
assert_eq!(pulse[0].message, "Add project");
|
||||
assert_eq!(pulse[1].message, "Add note");
|
||||
assert_eq!(pulse[0].files.len(), 1);
|
||||
assert_eq!(pulse[0].files[0].path, "project.md");
|
||||
assert_eq!(pulse[0].files[0].status, "added");
|
||||
assert_eq!(pulse[0].added, 1);
|
||||
assert_eq!(pulse[0].modified, 0);
|
||||
assert!(!pulse[0].short_hash.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_no_git() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vp = dir.path().to_str().unwrap();
|
||||
|
||||
let result = get_vault_pulse(vp, 30, 0);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Not a git repository"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_empty_repo() {
|
||||
let dir = setup_git_repo();
|
||||
let vp = vault_path(&dir);
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
||||
assert!(pulse.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_only_md_files() {
|
||||
let pulse = pulse_after_note_repo_change(NoteRepoChange::ConfigOnlyCommit);
|
||||
|
||||
assert_eq!(pulse.len(), 1);
|
||||
assert_eq!(pulse[0].files.len(), 1);
|
||||
assert_eq!(pulse[0].files[0].path, "note.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_respects_limit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault_path(&dir);
|
||||
|
||||
for i in 0..5 {
|
||||
fs::write(
|
||||
vault.join(format!("note{}.md", i)),
|
||||
format!("# Note {}\n", i),
|
||||
)
|
||||
.unwrap();
|
||||
git_commit(vp, &format!("Add note {}", i)).unwrap();
|
||||
}
|
||||
|
||||
let pulse = get_vault_pulse(vp, 3, 0).unwrap();
|
||||
assert_eq!(pulse.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_modified_and_deleted() {
|
||||
let pulse = pulse_after_note_repo_change(NoteRepoChange::NoteUpdateCommit);
|
||||
|
||||
assert_eq!(pulse[0].message, "Update note");
|
||||
assert_eq!(pulse[0].files[0].status, "modified");
|
||||
assert_eq!(pulse[0].modified, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_github_url() {
|
||||
let remote = GitHubRemote::OwnerRepo;
|
||||
let url = commit_url_for(CommitUrlSource::Pulse, remote);
|
||||
|
||||
assert!(url.starts_with(remote.commit_prefix()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_no_github_url_without_remote() {
|
||||
let dir = repo_with_committed_note();
|
||||
let vp = vault_path(&dir);
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
||||
assert!(pulse[0].github_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_from_path() {
|
||||
assert_eq!(
|
||||
title_from_path(VaultRelativePath("note/my-project.md")),
|
||||
"my project"
|
||||
);
|
||||
assert_eq!(title_from_path(VaultRelativePath("simple.md")), "simple");
|
||||
assert_eq!(
|
||||
title_from_path(VaultRelativePath("deep/nested/file.md")),
|
||||
"file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pulse_output_basic() {
|
||||
let stdout =
|
||||
"abc123|abc123d|Add notes|2026-03-05T10:00:00+01:00\nA\tnote.md\nM\tproject.md\n";
|
||||
let commits = parse_pulse_output(GitLogOutput(stdout), None);
|
||||
|
||||
assert_eq!(commits.len(), 1);
|
||||
assert_eq!(commits[0].message, "Add notes");
|
||||
assert_eq!(commits[0].files.len(), 2);
|
||||
assert_eq!(commits[0].files[0].status, "added");
|
||||
assert_eq!(commits[0].files[1].status, "modified");
|
||||
assert_eq!(commits[0].added, 1);
|
||||
assert_eq!(commits[0].modified, 1);
|
||||
assert!(commits[0].github_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pulse_output_with_github() {
|
||||
let stdout = "abc123|abc123d|Msg|2026-03-05T10:00:00+01:00\nA\tnote.md\n";
|
||||
let base = GitHubBaseUrl("https://github.com/o/r".to_string());
|
||||
let commits = parse_pulse_output(GitLogOutput(stdout), Some(&base));
|
||||
|
||||
assert_eq!(
|
||||
commits[0].github_url.as_deref(),
|
||||
Some("https://github.com/o/r/commit/abc123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pulse_output_multiple_commits() {
|
||||
let stdout = "aaa|aaa1234|First|2026-03-05T10:00:00+01:00\nA\ta.md\n\nbbb|bbb1234|Second|2026-03-04T10:00:00+01:00\nM\tb.md\nD\tc.md\n";
|
||||
let commits = parse_pulse_output(GitLogOutput(stdout), None);
|
||||
|
||||
assert_eq!(commits.len(), 2);
|
||||
assert_eq!(commits[0].message, "First");
|
||||
assert_eq!(commits[1].message, "Second");
|
||||
assert_eq!(commits[1].files.len(), 2);
|
||||
assert_eq!(commits[1].deleted, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_commit_info_with_commit() {
|
||||
let dir = repo_with_committed_note();
|
||||
let vp = vault_path(&dir);
|
||||
|
||||
let info = get_last_commit_info(vp).unwrap();
|
||||
assert!(info.is_some());
|
||||
let info = info.unwrap();
|
||||
assert_eq!(info.short_hash.len(), 7);
|
||||
assert!(info.commit_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_commit_info_no_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vp = vault_path(&dir);
|
||||
|
||||
let info = get_last_commit_info(vp).unwrap();
|
||||
assert!(info.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_commit_info_with_github_remote() {
|
||||
let remote = GitHubRemote::LaputaVault;
|
||||
let url = commit_url_for(CommitUrlSource::LastCommitInfo, remote);
|
||||
|
||||
assert!(url.starts_with(remote.commit_prefix()));
|
||||
}
|
||||
}
|
||||
594
product-source/hololake-platform/src-tauri/src/git/remote.rs
Normal file
594
product-source/hololake-platform/src-tauri/src/git/remote.rs
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
use super::command::{git_output, stderr_text, stdout_text};
|
||||
use super::conflict::get_conflict_files;
|
||||
use super::remote_config::has_configured_remote;
|
||||
use super::upstream::{missing_upstream_message, sync_target};
|
||||
|
||||
const NO_REMOTE_STATUS: &str = "no_remote";
|
||||
const NO_REMOTE_MESSAGE: &str = "No remote configured";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPullResult {
|
||||
pub status: String, // "up_to_date" | "updated" | "conflict" | "no_remote" | "error"
|
||||
pub message: String,
|
||||
#[serde(rename = "updatedFiles")]
|
||||
pub updated_files: Vec<String>,
|
||||
#[serde(rename = "conflictFiles")]
|
||||
pub conflict_files: Vec<String>,
|
||||
}
|
||||
|
||||
/// Check whether the vault repo has at least one remote configured.
|
||||
pub fn has_remote(vault_path: impl AsRef<Path>) -> Result<bool, String> {
|
||||
let vault = vault_path.as_ref();
|
||||
has_configured_remote(vault)
|
||||
}
|
||||
|
||||
/// Pull latest changes from remote. Uses --no-rebase to merge.
|
||||
/// Returns a structured result with status and affected files.
|
||||
pub fn git_pull(vault_path: impl AsRef<Path>) -> Result<GitPullResult, String> {
|
||||
let vault = vault_path.as_ref();
|
||||
|
||||
if !has_remote(vault)? {
|
||||
return Ok(GitPullResult {
|
||||
status: NO_REMOTE_STATUS.to_string(),
|
||||
message: NO_REMOTE_MESSAGE.to_string(),
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let target = match sync_target(vault)? {
|
||||
Some(target) => target,
|
||||
None => {
|
||||
return Ok(GitPullResult {
|
||||
status: "error".to_string(),
|
||||
message: missing_upstream_message(vault)?,
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let output = git_output(
|
||||
vault,
|
||||
&["pull", "--no-rebase", &target.remote, &target.branch],
|
||||
)
|
||||
.map_err(|e| format!("Failed to run git pull: {}", e))?;
|
||||
|
||||
let stdout = stdout_text(&output);
|
||||
let stderr = stderr_text(&output);
|
||||
|
||||
if output.status.success() {
|
||||
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
|
||||
return Ok(GitPullResult {
|
||||
status: "up_to_date".to_string(),
|
||||
message: "Already up to date".to_string(),
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
});
|
||||
}
|
||||
let updated = parse_updated_files(&stdout);
|
||||
return Ok(GitPullResult {
|
||||
status: "updated".to_string(),
|
||||
message: format!("{} file(s) updated", updated.len()),
|
||||
updated_files: updated,
|
||||
conflict_files: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Check for merge conflicts
|
||||
let vault_text = vault.to_string_lossy();
|
||||
let conflicts = get_conflict_files(vault_text.as_ref()).unwrap_or_default();
|
||||
if !conflicts.is_empty() {
|
||||
return Ok(GitPullResult {
|
||||
status: "conflict".to_string(),
|
||||
message: format!("Merge conflict in {} file(s)", conflicts.len()),
|
||||
updated_files: vec![],
|
||||
conflict_files: conflicts,
|
||||
});
|
||||
}
|
||||
|
||||
// Network error or other failure — report as error
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout.trim().to_string()
|
||||
} else {
|
||||
stderr.trim().to_string()
|
||||
};
|
||||
Ok(GitPullResult {
|
||||
status: "error".to_string(),
|
||||
message: detail,
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `git pull` output to extract updated file paths.
|
||||
fn parse_updated_files(stdout: &str) -> Vec<String> {
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
// Lines like " path/to/file.md | 5 ++-" in diffstat
|
||||
if trimmed.contains('|') {
|
||||
let path = trimmed.split('|').next()?.trim();
|
||||
if !path.is_empty() {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPushResult {
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "no_remote" | "error"
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum PushStatus {
|
||||
Rejected,
|
||||
AuthError,
|
||||
NetworkError,
|
||||
NoRemote,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl PushStatus {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rejected => "rejected",
|
||||
Self::AuthError => "auth_error",
|
||||
Self::NetworkError => "network_error",
|
||||
Self::NoRemote => "no_remote",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a git push stderr message into a user-friendly status and message.
|
||||
pub fn classify_push_error(stderr: impl AsRef<str>) -> GitPushResult {
|
||||
let stderr = stderr.as_ref();
|
||||
let lower = stderr.to_lowercase();
|
||||
|
||||
if is_rejected_push_error(&lower) {
|
||||
return push_error(
|
||||
PushStatus::Rejected,
|
||||
"Push rejected: remote has new commits. Pull first, then push.",
|
||||
);
|
||||
}
|
||||
|
||||
if is_auth_push_error(&lower) {
|
||||
return push_error(
|
||||
PushStatus::AuthError,
|
||||
"Push failed: authentication error. Check your credentials.",
|
||||
);
|
||||
}
|
||||
|
||||
if is_network_push_error(&lower) {
|
||||
return push_error(
|
||||
PushStatus::NetworkError,
|
||||
"Push failed: network error. Check your connection and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
if is_no_remote_push_error(&lower) {
|
||||
return push_error(PushStatus::NoRemote, "No remote configured");
|
||||
}
|
||||
|
||||
push_error(
|
||||
PushStatus::Error,
|
||||
format!("Push failed: {}", push_error_detail(stderr)),
|
||||
)
|
||||
}
|
||||
|
||||
fn push_error(status: PushStatus, message: impl Into<String>) -> GitPushResult {
|
||||
GitPushResult {
|
||||
status: status.as_str().to_string(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
|
||||
needles.iter().any(|needle| haystack.contains(needle))
|
||||
}
|
||||
|
||||
fn is_rejected_push_error(lower: impl AsRef<str>) -> bool {
|
||||
let lower = lower.as_ref();
|
||||
if contains_any(lower, &["non-fast-forward", "[rejected]", "fetch first"]) {
|
||||
return true;
|
||||
}
|
||||
if !lower.contains("failed to push some refs") {
|
||||
return false;
|
||||
}
|
||||
contains_any(lower, &["updates were rejected", "non-fast-forward"])
|
||||
}
|
||||
|
||||
fn is_auth_push_error(lower: impl AsRef<str>) -> bool {
|
||||
contains_any(
|
||||
lower.as_ref(),
|
||||
&[
|
||||
"authentication failed",
|
||||
"could not read username",
|
||||
"permission denied",
|
||||
"403",
|
||||
"invalid credentials",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn is_network_push_error(lower: impl AsRef<str>) -> bool {
|
||||
contains_any(
|
||||
lower.as_ref(),
|
||||
&[
|
||||
"could not resolve host",
|
||||
"unable to access",
|
||||
"connection refused",
|
||||
"network is unreachable",
|
||||
"timed out",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn is_no_remote_push_error(lower: impl AsRef<str>) -> bool {
|
||||
contains_any(
|
||||
lower.as_ref(),
|
||||
&[
|
||||
"no configured push destination",
|
||||
"does not appear to be a git repository",
|
||||
"no such remote",
|
||||
"no upstream branch",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn push_error_detail(stderr: &str) -> String {
|
||||
let hint_line = stderr
|
||||
.lines()
|
||||
.find(|line| line.trim_start().starts_with("hint:"))
|
||||
.map(|line| {
|
||||
line.trim_start()
|
||||
.strip_prefix("hint:")
|
||||
.unwrap_or(line)
|
||||
.trim()
|
||||
})
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if hint_line.is_empty() {
|
||||
stderr.trim().to_string()
|
||||
} else {
|
||||
hint_line
|
||||
}
|
||||
}
|
||||
|
||||
/// Push to remote.
|
||||
pub fn git_push(vault_path: impl AsRef<Path>) -> Result<GitPushResult, String> {
|
||||
let vault = vault_path.as_ref();
|
||||
|
||||
if !has_remote(vault)? {
|
||||
return Ok(GitPushResult {
|
||||
status: NO_REMOTE_STATUS.to_string(),
|
||||
message: NO_REMOTE_MESSAGE.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let target = match sync_target(vault)? {
|
||||
Some(target) => target,
|
||||
None => {
|
||||
return Ok(GitPushResult {
|
||||
status: "error".to_string(),
|
||||
message: missing_upstream_message(vault)?,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let push_refspec = format!("HEAD:refs/heads/{}", target.branch);
|
||||
let output = git_output(vault, &["push", &target.remote, &push_refspec])
|
||||
.map_err(|e| format!("Failed to run git push: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = stderr_text(&output);
|
||||
return Ok(classify_push_error(&stderr));
|
||||
}
|
||||
|
||||
Ok(GitPushResult {
|
||||
status: "ok".to_string(),
|
||||
message: "Pushed to remote".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_command;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct RemotePair {
|
||||
_bare: TempDir,
|
||||
clone_a: TempDir,
|
||||
clone_b: TempDir,
|
||||
}
|
||||
|
||||
impl RemotePair {
|
||||
fn new() -> Self {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
|
||||
Self {
|
||||
_bare,
|
||||
clone_a,
|
||||
clone_b,
|
||||
}
|
||||
}
|
||||
|
||||
fn seeded() -> Self {
|
||||
let pair = Self::new();
|
||||
commit_default_note(pair.clone_a.path());
|
||||
git_push(pair.vault_a()).unwrap();
|
||||
pair
|
||||
}
|
||||
|
||||
fn vault_a(&self) -> &str {
|
||||
path_text(self.clone_a.path())
|
||||
}
|
||||
|
||||
fn vault_b(&self) -> &str {
|
||||
path_text(self.clone_b.path())
|
||||
}
|
||||
|
||||
fn sync_b(&self) {
|
||||
git_pull(self.vault_b()).unwrap();
|
||||
}
|
||||
|
||||
fn update_a_note(&self) {
|
||||
fs::write(self.clone_a.path().join("note.md"), "# Updated\n").unwrap();
|
||||
git_commit(self.vault_a(), "update").unwrap();
|
||||
}
|
||||
|
||||
fn push_a(&self) {
|
||||
git_push(self.vault_a()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn path_text(path: &Path) -> &str {
|
||||
path.to_str().unwrap()
|
||||
}
|
||||
|
||||
fn local_repo_with_note() -> TempDir {
|
||||
let dir = setup_git_repo();
|
||||
commit_default_note(dir.path());
|
||||
dir
|
||||
}
|
||||
|
||||
fn commit_default_note(vault_path: &Path) {
|
||||
fs::write(vault_path.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vault_path.to_str().unwrap(), "initial").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_returns_false_for_local_repo() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = path_text(vault);
|
||||
|
||||
assert!(!has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_returns_true_when_remote_exists() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = path_text(vault);
|
||||
|
||||
git_command()
|
||||
.args(["remote", "add", "origin", "https://example.com/repo.git"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_ignores_name_only_remote_without_url() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = path_text(vault);
|
||||
|
||||
git_command()
|
||||
.args(["config", "remote.origin.prune", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let remote_names = git_command()
|
||||
.args(["remote"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&remote_names.stdout).contains("origin"));
|
||||
assert!(!has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_no_remote_returns_no_remote() {
|
||||
let dir = local_repo_with_note();
|
||||
let vp = path_text(dir.path());
|
||||
|
||||
let result = git_pull(vp).unwrap();
|
||||
assert_eq!(result.status, "no_remote");
|
||||
assert!(result.updated_files.is_empty());
|
||||
assert!(result.conflict_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_up_to_date() {
|
||||
let pair = RemotePair::seeded();
|
||||
|
||||
let result = git_pull(pair.vault_a()).unwrap();
|
||||
assert_eq!(result.status, "up_to_date");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_updated_files() {
|
||||
let pair = RemotePair::seeded();
|
||||
pair.sync_b();
|
||||
pair.update_a_note();
|
||||
pair.push_a();
|
||||
|
||||
let result = git_pull(pair.vault_b()).unwrap();
|
||||
assert_eq!(result.status, "updated");
|
||||
assert!(result.conflict_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_updated_files_diffstat() {
|
||||
let stdout =
|
||||
" Fast-forward\n note.md | 2 +-\n project/plan.md | 4 ++--\n 2 files changed\n";
|
||||
let files = parse_updated_files(stdout);
|
||||
assert_eq!(files, vec!["note.md", "project/plan.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_updated_files_empty() {
|
||||
let stdout = "Already up to date.\n";
|
||||
let files = parse_updated_files(stdout);
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_non_fast_forward() {
|
||||
let stderr = r#"To github.com:user/repo.git
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'github.com:user/repo.git'
|
||||
hint: Updates were rejected because the remote contains work that you do not
|
||||
hint: have locally."#;
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "rejected");
|
||||
assert!(result.message.contains("Pull first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_fetch_first() {
|
||||
let stderr = "error: failed to push some refs\nhint: Updates were rejected because the tip of your current branch is behind\nhint: its remote counterpart. Integrate the remote changes (e.g.\nhint: 'git pull ...') before pushing again.\nhint: See the 'Note about fast-forwards' in 'git push --help' for details.\n ! [rejected] main -> main (fetch first)\n";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_auth_failure() {
|
||||
let stderr = "remote: Permission denied to user/repo.git\nfatal: unable to access 'https://github.com/user/repo.git/': The requested URL returned error: 403";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "auth_error");
|
||||
assert!(result.message.contains("authentication"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_network() {
|
||||
let stderr = "fatal: unable to access 'https://github.com/user/repo.git/': Could not resolve host: github.com";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "network_error");
|
||||
assert!(result.message.contains("network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_no_remote() {
|
||||
let stderr = "fatal: No configured push destination.";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "no_remote");
|
||||
assert!(result.message.contains("No remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_unknown() {
|
||||
let stderr = "error: something unexpected happened\nhint: Try again later";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "error");
|
||||
assert!(result.message.contains("Try again later"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_unknown_no_hint() {
|
||||
let stderr = "error: something totally weird";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "error");
|
||||
assert!(result.message.contains("something totally weird"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_result_serialization() {
|
||||
let result = GitPushResult {
|
||||
status: "rejected".to_string(),
|
||||
message: "Push rejected".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"rejected\""));
|
||||
let parsed: GitPushResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.status, "rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_success_returns_ok() {
|
||||
let pair = RemotePair::new();
|
||||
|
||||
commit_default_note(pair.clone_a.path());
|
||||
let result = git_push(pair.vault_a()).unwrap();
|
||||
assert_eq!(result.status, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_no_remote_returns_no_remote() {
|
||||
let dir = local_repo_with_note();
|
||||
let vp = path_text(dir.path());
|
||||
|
||||
let result = git_push(vp).unwrap();
|
||||
assert_eq!(result.status, "no_remote");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_rejected_returns_rejected() {
|
||||
let pair = RemotePair::new();
|
||||
let vp_a = pair.vault_a();
|
||||
let vp_b = pair.vault_b();
|
||||
|
||||
// Both clones commit and push — second push should be rejected
|
||||
fs::write(pair.clone_a.path().join("note.md"), "# A\n").unwrap();
|
||||
git_commit(vp_a, "from A").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
git_pull(vp_b).unwrap();
|
||||
fs::write(pair.clone_b.path().join("note.md"), "# B\n").unwrap();
|
||||
git_commit(vp_b, "from B").unwrap();
|
||||
git_push(vp_b).unwrap();
|
||||
|
||||
// Now A has a new commit but hasn't pulled B's changes
|
||||
fs::write(pair.clone_a.path().join("other.md"), "# Other\n").unwrap();
|
||||
git_commit(vp_a, "from A again").unwrap();
|
||||
let result = git_push(vp_a).unwrap();
|
||||
assert_eq!(result.status, "rejected");
|
||||
assert!(result.message.contains("Pull first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_result_serialization() {
|
||||
let result = GitPullResult {
|
||||
status: "updated".to_string(),
|
||||
message: "2 file(s) updated".to_string(),
|
||||
updated_files: vec!["note.md".to_string()],
|
||||
conflict_files: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"updatedFiles\""));
|
||||
assert!(json.contains("\"conflictFiles\""));
|
||||
|
||||
let parsed: GitPullResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.status, "updated");
|
||||
assert_eq!(parsed.updated_files.len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::git_command;
|
||||
use super::tests::setup_remote_pair;
|
||||
use super::{git_commit, git_pull, git_push, git_remote_status};
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct RemotePair {
|
||||
_bare: TempDir,
|
||||
clone_a: TempDir,
|
||||
clone_b: TempDir,
|
||||
}
|
||||
|
||||
impl RemotePair {
|
||||
fn seeded() -> Self {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(path_text(clone_a.path()), "initial").unwrap();
|
||||
git_push(path_text(clone_a.path())).unwrap();
|
||||
Self {
|
||||
_bare,
|
||||
clone_a,
|
||||
clone_b,
|
||||
}
|
||||
}
|
||||
|
||||
fn vault_a(&self) -> &str {
|
||||
path_text(self.clone_a.path())
|
||||
}
|
||||
|
||||
fn vault_b(&self) -> &str {
|
||||
path_text(self.clone_b.path())
|
||||
}
|
||||
}
|
||||
|
||||
fn path_text(path: &Path) -> &str {
|
||||
path.to_str().unwrap()
|
||||
}
|
||||
|
||||
fn run_git(vault_path: &Path, args: &[&str]) {
|
||||
let output = git_command()
|
||||
.args(args)
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_pull_and_push_use_configured_upstream_branch() {
|
||||
let pair = RemotePair::seeded();
|
||||
|
||||
run_git(pair.clone_a.path(), &["checkout", "-b", "local-draft"]);
|
||||
run_git(
|
||||
pair.clone_a.path(),
|
||||
&["push", "-u", "origin", "HEAD:refs/heads/review-target"],
|
||||
);
|
||||
run_git(pair.clone_b.path(), &["fetch", "origin"]);
|
||||
run_git(
|
||||
pair.clone_b.path(),
|
||||
&["checkout", "-b", "review-copy", "origin/review-target"],
|
||||
);
|
||||
|
||||
fs::write(
|
||||
pair.clone_a.path().join("branch-note.md"),
|
||||
"# Branch note\n",
|
||||
)
|
||||
.unwrap();
|
||||
git_commit(pair.vault_a(), "branch update").unwrap();
|
||||
|
||||
let push = git_push(pair.vault_a()).unwrap();
|
||||
assert_eq!(push.status, "ok");
|
||||
|
||||
let pull = git_pull(pair.vault_b()).unwrap();
|
||||
assert_eq!(pull.status, "updated");
|
||||
assert_eq!(
|
||||
fs::read_to_string(pair.clone_b.path().join("branch-note.md")).unwrap(),
|
||||
"# Branch note\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_remote_status_reports_missing_upstream() {
|
||||
let pair = RemotePair::seeded();
|
||||
run_git(pair.clone_a.path(), &["checkout", "-b", "local-only"]);
|
||||
|
||||
let status = git_remote_status(pair.vault_a()).unwrap();
|
||||
assert!(status.has_remote);
|
||||
assert_eq!(status.branch, "local-only");
|
||||
assert!(!status.has_upstream);
|
||||
assert_eq!(status.upstream, None);
|
||||
|
||||
let pull = git_pull(pair.vault_a()).unwrap();
|
||||
assert_eq!(pull.status, "error");
|
||||
assert!(pull.message.contains("No upstream branch configured"));
|
||||
|
||||
let push = git_push(pair.vault_a()).unwrap();
|
||||
assert_eq!(push.status, "error");
|
||||
assert!(push.message.contains("No upstream branch configured"));
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
use std::path::Path;
|
||||
|
||||
use super::command::{git_output, stderr_or_failure, stdout_lines};
|
||||
|
||||
const DEFAULT_FETCH_REFSPEC: &str = "+refs/heads/*:refs/remotes/origin/*";
|
||||
const REMOTE_URL_CONFIG_PATTERN: &str = r"^remote\..*\.url$";
|
||||
const ORIGIN_URL_CONFIG_KEY: &str = "remote.origin.url";
|
||||
const ORIGIN_FETCH_CONFIG_KEY: &str = "remote.origin.fetch";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ConfiguredRemote {
|
||||
name: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
pub(super) fn has_configured_remote(vault: &Path) -> Result<bool, String> {
|
||||
Ok(!list_configured_remotes(vault)?.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn list_configured_remotes(vault: &Path) -> Result<Vec<String>, String> {
|
||||
Ok(list_configured_remote_urls(vault)?
|
||||
.into_iter()
|
||||
.map(|remote| remote.name)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) fn primary_remote_url(vault: &Path) -> Result<Option<String>, String> {
|
||||
let remotes = list_configured_remote_urls(vault)?;
|
||||
Ok(remotes
|
||||
.iter()
|
||||
.find(|remote| remote.name == "origin")
|
||||
.or_else(|| remotes.first())
|
||||
.map(|remote| remote.url.clone()))
|
||||
}
|
||||
|
||||
fn list_configured_remote_urls(vault: &Path) -> Result<Vec<ConfiguredRemote>, String> {
|
||||
let output = git_output(
|
||||
vault,
|
||||
&["config", "--get-regexp", REMOTE_URL_CONFIG_PATTERN],
|
||||
)
|
||||
.map_err(|e| format!("Failed to inspect git remotes: {e}"))?;
|
||||
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(stderr_or_failure("git config --get-regexp", &output));
|
||||
}
|
||||
|
||||
Ok(stdout_lines(&output)
|
||||
.into_iter()
|
||||
.filter_map(|line| remote_from_url_config(&line))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn remote_from_url_config(line: &str) -> Option<ConfiguredRemote> {
|
||||
let (key, value) = line.split_once(' ')?;
|
||||
let url = value.trim();
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = key
|
||||
.strip_prefix("remote.")
|
||||
.and_then(|name| name.strip_suffix(".url"))
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToString::to_string)?;
|
||||
|
||||
Some(ConfiguredRemote {
|
||||
name,
|
||||
url: url.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn configure_origin_remote(vault: &Path, remote_url: &str) -> Result<(), String> {
|
||||
run_git_config(vault, ORIGIN_URL_CONFIG_KEY, remote_url)?;
|
||||
run_git_config(vault, ORIGIN_FETCH_CONFIG_KEY, DEFAULT_FETCH_REFSPEC)
|
||||
}
|
||||
|
||||
fn run_git_config(vault: &Path, key: &str, value: &str) -> Result<(), String> {
|
||||
let output = git_output(vault, &["config", "--local", "--replace-all", key, value])
|
||||
.map_err(|e| format!("Failed to run git config: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(stderr_or_failure("git config", &output))
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
use super::command::{git_output, git_output_result, stdout_text};
|
||||
use super::remote_config::has_configured_remote;
|
||||
use super::upstream::{branch_label, sync_target};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitRemoteStatus {
|
||||
pub branch: String,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
#[serde(rename = "hasRemote")]
|
||||
pub has_remote: bool,
|
||||
#[serde(rename = "hasUpstream")]
|
||||
pub has_upstream: bool,
|
||||
pub upstream: Option<String>,
|
||||
}
|
||||
|
||||
/// Get the current branch name, and how many commits ahead/behind the upstream.
|
||||
pub fn git_remote_status(vault_path: impl AsRef<Path>) -> Result<GitRemoteStatus, String> {
|
||||
let vault = vault_path.as_ref();
|
||||
let branch = branch_label(vault)?;
|
||||
|
||||
if !has_configured_remote(vault)? {
|
||||
return Ok(status_without_remote(branch));
|
||||
}
|
||||
|
||||
// Fetch latest remote refs (silent, best-effort)
|
||||
let _ = git_output(vault, &["fetch", "--quiet"]);
|
||||
|
||||
let Some(target) = sync_target(vault)? else {
|
||||
return Ok(status_without_upstream(branch));
|
||||
};
|
||||
|
||||
let output = git_output_result(
|
||||
vault,
|
||||
&[
|
||||
"rev-list",
|
||||
"--left-right",
|
||||
"--count",
|
||||
&format!("HEAD...{}", target.display),
|
||||
],
|
||||
)?;
|
||||
|
||||
let (ahead, behind) = if output.status.success() {
|
||||
parse_ahead_behind(&stdout_text(&output))
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
Ok(status_with_upstream(branch, target.display, ahead, behind))
|
||||
}
|
||||
|
||||
fn status_without_remote(branch: String) -> GitRemoteStatus {
|
||||
GitRemoteStatus {
|
||||
branch,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
has_remote: false,
|
||||
has_upstream: false,
|
||||
upstream: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_without_upstream(branch: String) -> GitRemoteStatus {
|
||||
GitRemoteStatus {
|
||||
branch,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
has_remote: true,
|
||||
has_upstream: false,
|
||||
upstream: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_with_upstream(
|
||||
branch: String,
|
||||
upstream: String,
|
||||
ahead: u32,
|
||||
behind: u32,
|
||||
) -> GitRemoteStatus {
|
||||
GitRemoteStatus {
|
||||
branch,
|
||||
ahead,
|
||||
behind,
|
||||
has_remote: true,
|
||||
has_upstream: true,
|
||||
upstream: Some(upstream),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ahead_behind(stdout: &str) -> (u32, u32) {
|
||||
let parts: Vec<&str> = stdout.split('\t').collect();
|
||||
let ahead = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let behind = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
(ahead, behind)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use crate::git::{git_commit, git_pull, git_push};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct RemotePair {
|
||||
_bare: TempDir,
|
||||
clone_a: TempDir,
|
||||
clone_b: TempDir,
|
||||
}
|
||||
|
||||
impl RemotePair {
|
||||
fn seeded() -> Self {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
commit_default_note(clone_a.path());
|
||||
git_push(path_text(clone_a.path())).unwrap();
|
||||
Self {
|
||||
_bare,
|
||||
clone_a,
|
||||
clone_b,
|
||||
}
|
||||
}
|
||||
|
||||
fn vault_a(&self) -> &str {
|
||||
path_text(self.clone_a.path())
|
||||
}
|
||||
|
||||
fn sync_b(&self) {
|
||||
git_pull(path_text(self.clone_b.path())).unwrap();
|
||||
}
|
||||
|
||||
fn update_a_note(&self) {
|
||||
fs::write(self.clone_a.path().join("note.md"), "# Updated\n").unwrap();
|
||||
git_commit(self.vault_a(), "update").unwrap();
|
||||
}
|
||||
|
||||
fn update_b_note(&self) {
|
||||
fs::write(self.clone_b.path().join("note.md"), "# B update\n").unwrap();
|
||||
git_commit(path_text(self.clone_b.path()), "from B").unwrap();
|
||||
}
|
||||
|
||||
fn push_b(&self) {
|
||||
git_push(path_text(self.clone_b.path())).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn path_text(path: &Path) -> &str {
|
||||
path.to_str().unwrap()
|
||||
}
|
||||
|
||||
fn local_repo_with_note() -> TempDir {
|
||||
let dir = setup_git_repo();
|
||||
commit_default_note(dir.path());
|
||||
dir
|
||||
}
|
||||
|
||||
fn commit_default_note(vault_path: &Path) {
|
||||
fs::write(vault_path.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(path_text(vault_path), "initial").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_remote_status_no_remote() {
|
||||
let dir = local_repo_with_note();
|
||||
let status = git_remote_status(path_text(dir.path())).unwrap();
|
||||
assert!(!status.has_remote);
|
||||
assert_eq!(status.ahead, 0);
|
||||
assert_eq!(status.behind, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_remote_status_up_to_date() {
|
||||
let pair = RemotePair::seeded();
|
||||
let status = git_remote_status(pair.vault_a()).unwrap();
|
||||
assert!(status.has_remote);
|
||||
assert!(status.has_upstream);
|
||||
assert_eq!(status.upstream.as_deref(), Some("origin/main"));
|
||||
assert_eq!(status.ahead, 0);
|
||||
assert_eq!(status.behind, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_remote_status_ahead() {
|
||||
let pair = RemotePair::seeded();
|
||||
pair.update_a_note();
|
||||
let status = git_remote_status(pair.vault_a()).unwrap();
|
||||
assert_eq!(status.ahead, 1);
|
||||
assert_eq!(status.behind, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_remote_status_behind() {
|
||||
let pair = RemotePair::seeded();
|
||||
pair.sync_b();
|
||||
pair.update_b_note();
|
||||
pair.push_b();
|
||||
let status = git_remote_status(pair.vault_a()).unwrap();
|
||||
assert_eq!(status.behind, 1);
|
||||
assert_eq!(status.ahead, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_remote_status_serialization() {
|
||||
let status = GitRemoteStatus {
|
||||
branch: "main".to_string(),
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
has_remote: true,
|
||||
has_upstream: true,
|
||||
upstream: Some("origin/main".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert!(json.contains("\"hasRemote\""));
|
||||
assert!(json.contains("\"hasUpstream\""));
|
||||
let parsed: GitRemoteStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.branch, "main");
|
||||
assert_eq!(parsed.ahead, 2);
|
||||
assert_eq!(parsed.upstream.as_deref(), Some("origin/main"));
|
||||
}
|
||||
}
|
||||
132
product-source/hololake-platform/src-tauri/src/git/remote_url.rs
Normal file
132
product-source/hololake-platform/src-tauri/src/git/remote_url.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
const ALLOWED_REMOTE_URL_MESSAGE: &str =
|
||||
"Repository URL must start with https://, http://, ssh://, or git@host:path.";
|
||||
const HIERARCHICAL_REMOTE_SCHEMES: [&str; 3] = ["https://", "http://", "ssh://"];
|
||||
|
||||
pub(crate) fn validate_user_remote_url(remote_url: &str) -> Result<&str, String> {
|
||||
let trimmed = remote_url.trim();
|
||||
|
||||
if let Some(message) = invalid_remote_url_message(trimmed) {
|
||||
return Err(message.to_string());
|
||||
}
|
||||
|
||||
if is_supported_remote_url(trimmed) {
|
||||
return Ok(trimmed);
|
||||
}
|
||||
|
||||
Err(ALLOWED_REMOTE_URL_MESSAGE.to_string())
|
||||
}
|
||||
|
||||
fn invalid_remote_url_message(remote_url: &str) -> Option<&'static str> {
|
||||
if remote_url.is_empty() {
|
||||
return Some("Enter a repository URL before continuing.");
|
||||
}
|
||||
|
||||
if remote_url.starts_with('-') {
|
||||
return Some("Repository URL cannot start with '-'.");
|
||||
}
|
||||
|
||||
if contains_unsafe_url_character(remote_url) {
|
||||
return Some(ALLOWED_REMOTE_URL_MESSAGE);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn contains_unsafe_url_character(remote_url: &str) -> bool {
|
||||
remote_url
|
||||
.chars()
|
||||
.any(|ch| ch.is_control() || ch.is_whitespace())
|
||||
}
|
||||
|
||||
fn is_supported_remote_url(remote_url: &str) -> bool {
|
||||
HIERARCHICAL_REMOTE_SCHEMES
|
||||
.iter()
|
||||
.any(|scheme| is_hierarchical_remote_url(remote_url, scheme))
|
||||
|| is_git_scp_remote_url(remote_url)
|
||||
}
|
||||
|
||||
fn is_hierarchical_remote_url(url: &str, scheme: &str) -> bool {
|
||||
let Some(rest) = strip_ascii_prefix(url, scheme) else {
|
||||
return false;
|
||||
};
|
||||
let Some((authority, path)) = rest.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
|
||||
has_valid_remote_host(authority) && !path.is_empty()
|
||||
}
|
||||
|
||||
fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
value
|
||||
.get(..prefix.len())
|
||||
.filter(|candidate| candidate.eq_ignore_ascii_case(prefix))
|
||||
.map(|_| &value[prefix.len()..])
|
||||
}
|
||||
|
||||
fn is_git_scp_remote_url(url: &str) -> bool {
|
||||
let Some(rest) = url.strip_prefix("git@") else {
|
||||
return false;
|
||||
};
|
||||
let Some((host, path)) = rest.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
|
||||
has_valid_remote_host(host) && !path.is_empty()
|
||||
}
|
||||
|
||||
fn has_valid_remote_host(authority: &str) -> bool {
|
||||
let host = host_from_authority(authority);
|
||||
|
||||
!host.is_empty() && !host.starts_with('-')
|
||||
}
|
||||
|
||||
fn host_from_authority(authority: &str) -> &str {
|
||||
let host = authority
|
||||
.rsplit_once('@')
|
||||
.map_or(authority, |(_, host)| host);
|
||||
|
||||
host.split_once(':').map_or(host, |(host, _)| host)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_user_remote_url_accepts_supported_remote_forms() {
|
||||
for url in [
|
||||
"https://github.com/refactoringhq/tolaria.git",
|
||||
"http://git.example.test/org/repo.git",
|
||||
"ssh://git@git.example.test/org/repo.git",
|
||||
"git@github.com:refactoringhq/tolaria.git",
|
||||
] {
|
||||
assert_eq!(validate_user_remote_url(url).unwrap(), url);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_user_remote_url_trims_supported_urls() {
|
||||
assert_eq!(
|
||||
validate_user_remote_url(" https://github.com/refactoringhq/tolaria.git ").unwrap(),
|
||||
"https://github.com/refactoringhq/tolaria.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_user_remote_url_rejects_dangerous_or_unsupported_inputs() {
|
||||
for url in [
|
||||
"",
|
||||
"--upload-pack=touch-pwned",
|
||||
"ext::sh -c touch-pwned %0.git",
|
||||
"file:///Users/luca/private.git",
|
||||
"/Users/luca/private.git",
|
||||
"github.com:refactoringhq/tolaria.git",
|
||||
"git@-oProxyCommand=touch-pwned:repo.git",
|
||||
"https://",
|
||||
"ssh://git@example.com",
|
||||
"https://github.com/refactoringhq/tolaria with space.git",
|
||||
] {
|
||||
assert!(validate_user_remote_url(url).is_err(), "{url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
708
product-source/hololake-platform/src-tauri/src/git/status.rs
Normal file
708
product-source/hololake-platform/src-tauri/src/git/status.rs
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
use super::git_command_at;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ModifiedFile {
|
||||
pub path: String,
|
||||
#[serde(rename = "relativePath")]
|
||||
pub relative_path: String,
|
||||
pub status: String,
|
||||
#[serde(rename = "addedLines")]
|
||||
pub added_lines: Option<usize>,
|
||||
#[serde(rename = "deletedLines")]
|
||||
pub deleted_lines: Option<usize>,
|
||||
pub binary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct DiffStats {
|
||||
added_lines: Option<usize>,
|
||||
deleted_lines: Option<usize>,
|
||||
binary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct StatusEntry {
|
||||
status_code: String,
|
||||
relative_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FileChangeStatus {
|
||||
Modified,
|
||||
Added,
|
||||
Deleted,
|
||||
Untracked,
|
||||
Renamed,
|
||||
}
|
||||
|
||||
impl FileChangeStatus {
|
||||
fn from_code(status_code: &str) -> Self {
|
||||
match status_code.trim() {
|
||||
"A" => Self::Added,
|
||||
"D" => Self::Deleted,
|
||||
"??" => Self::Untracked,
|
||||
"R" | "RM" => Self::Renamed,
|
||||
_ => Self::Modified,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Added => "added",
|
||||
Self::Deleted => "deleted",
|
||||
Self::Untracked => "untracked",
|
||||
Self::Renamed => "renamed",
|
||||
Self::Modified => "modified",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn split_nul_fields(output: &[u8]) -> Vec<String> {
|
||||
output
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|field| !field.is_empty())
|
||||
.map(|field| String::from_utf8_lossy(field).into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn status_has_source_path(status_code: &str) -> bool {
|
||||
status_code.contains('R') || status_code.contains('C')
|
||||
}
|
||||
|
||||
fn parse_status_field(field: &str) -> Option<StatusEntry> {
|
||||
if field.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(StatusEntry {
|
||||
status_code: field[..2].to_string(),
|
||||
relative_path: field[3..].to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_status_output(output: &[u8]) -> Vec<StatusEntry> {
|
||||
let fields = split_nul_fields(output);
|
||||
let mut entries = Vec::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < fields.len() {
|
||||
let Some(entry) = parse_status_field(&fields[index]) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
let has_source_path = status_has_source_path(&entry.status_code);
|
||||
entries.push(entry);
|
||||
index += if has_source_path { 2 } else { 1 };
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn parse_numstat_field(field: &str) -> Option<usize> {
|
||||
field.parse().ok()
|
||||
}
|
||||
|
||||
fn parse_numstat_header(header: &str) -> Option<(Option<String>, DiffStats)> {
|
||||
let mut parts = header.splitn(3, '\t');
|
||||
let added = parts.next()?;
|
||||
let deleted = parts.next()?;
|
||||
let path = parts.next()?;
|
||||
|
||||
let added_lines = parse_numstat_field(added);
|
||||
let deleted_lines = parse_numstat_field(deleted);
|
||||
let binary = added == "-" || deleted == "-";
|
||||
|
||||
Some((
|
||||
(!path.is_empty()).then(|| path.to_string()),
|
||||
DiffStats {
|
||||
added_lines,
|
||||
deleted_lines,
|
||||
binary,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_numstat_output(output: &[u8]) -> HashMap<String, DiffStats> {
|
||||
let fields = split_nul_fields(output);
|
||||
let mut stats = HashMap::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < fields.len() {
|
||||
let Some((path, diff_stats)) = parse_numstat_header(&fields[index]) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
match path {
|
||||
Some(path) => {
|
||||
stats.insert(path, diff_stats);
|
||||
index += 1;
|
||||
}
|
||||
None if index + 2 < fields.len() => {
|
||||
stats.insert(fields[index + 2].clone(), diff_stats);
|
||||
index += 3;
|
||||
}
|
||||
None => {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats
|
||||
}
|
||||
|
||||
fn repo_has_head(vault: &Path) -> Result<bool, String> {
|
||||
let output = git_command_at(vault)
|
||||
.and_then(|mut command| command.args(["rev-parse", "--verify", "HEAD"]).output())
|
||||
.map_err(|e| format!("Failed to run git rev-parse: {e}"))?;
|
||||
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
|
||||
if !repo_has_head(vault)? {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let output = git_command_at(vault)
|
||||
.and_then(|mut command| {
|
||||
command
|
||||
.args(["diff", "--numstat", "-z", "--find-renames", "HEAD", "--"])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| format!("Failed to run git diff --numstat: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git diff --numstat failed: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
Ok(parse_numstat_output(&output.stdout))
|
||||
}
|
||||
|
||||
fn count_worktree_lines(vault: &Path, relative_path: &Path) -> DiffStats {
|
||||
let full_path = vault.join(relative_path);
|
||||
let added_lines = std::fs::read_to_string(full_path)
|
||||
.ok()
|
||||
.map(|content| content.lines().count());
|
||||
|
||||
DiffStats {
|
||||
added_lines,
|
||||
deleted_lines: None,
|
||||
binary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_diff_stats(
|
||||
vault: &Path,
|
||||
relative_path: &Path,
|
||||
status: FileChangeStatus,
|
||||
diff_stats: &HashMap<String, DiffStats>,
|
||||
include_stats: bool,
|
||||
) -> DiffStats {
|
||||
if !include_stats {
|
||||
return DiffStats::default();
|
||||
}
|
||||
|
||||
if status == FileChangeStatus::Untracked {
|
||||
return count_worktree_lines(vault, relative_path);
|
||||
}
|
||||
|
||||
let key = relative_path.to_string_lossy();
|
||||
diff_stats.get(key.as_ref()).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ensure_path_within_vault(vault: &Path, relative_path: &Path, abs: &Path) -> Result<(), String> {
|
||||
for component in relative_path.components() {
|
||||
if matches!(component, std::path::Component::ParentDir) {
|
||||
return Err("File path is outside the vault".into());
|
||||
}
|
||||
}
|
||||
|
||||
if !abs.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let canonical_vault = vault
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot resolve vault path: {e}"))?;
|
||||
let canonical_file = abs
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot resolve file path: {e}"))?;
|
||||
|
||||
if canonical_file.starts_with(&canonical_vault) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("File path is outside the vault".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn load_file_status(vault: &Path, relative_path: &Path) -> Result<String, String> {
|
||||
let output = git_command_at(vault)
|
||||
.and_then(|mut command| {
|
||||
command
|
||||
.args(["status", "--porcelain", "--"])
|
||||
.arg(relative_path)
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| format!("Failed to run git status: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.find(|line| line.len() >= 4)
|
||||
.map(|line| line[..2].trim().to_string())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn restore_tracked_file(vault: &Path, relative_path: &Path) -> Result<(), String> {
|
||||
let _ = git_command_at(vault).and_then(|mut command| {
|
||||
command
|
||||
.args(["reset", "HEAD", "--"])
|
||||
.arg(relative_path)
|
||||
.output()
|
||||
});
|
||||
|
||||
let checkout = git_command_at(vault)
|
||||
.and_then(|mut command| command.args(["checkout", "--"]).arg(relative_path).output())
|
||||
.map_err(|e| format!("Failed to run git checkout: {e}"))?;
|
||||
|
||||
if checkout.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&checkout.stderr);
|
||||
Err(format!("git checkout failed: {}", stderr.trim()))
|
||||
}
|
||||
|
||||
/// Get list of modified/added/deleted files in the vault (uncommitted changes).
|
||||
pub fn get_modified_files(vault_path: impl AsRef<Path>) -> Result<Vec<ModifiedFile>, String> {
|
||||
get_modified_files_impl(vault_path.as_ref(), false)
|
||||
}
|
||||
|
||||
/// Get list of modified/added/deleted files with line-level diff statistics.
|
||||
pub fn get_modified_files_with_stats(
|
||||
vault_path: impl AsRef<Path>,
|
||||
) -> Result<Vec<ModifiedFile>, String> {
|
||||
get_modified_files_impl(vault_path.as_ref(), true)
|
||||
}
|
||||
|
||||
fn get_modified_files_impl(vault: &Path, include_stats: bool) -> Result<Vec<ModifiedFile>, String> {
|
||||
if !super::is_inside_work_tree(vault) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let output = git_command_at(vault)
|
||||
.and_then(|mut command| {
|
||||
command
|
||||
.args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
|
||||
.output()
|
||||
})
|
||||
.map_err(|e| format!("Failed to run git status: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git status failed: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let diff_stats = if include_stats {
|
||||
load_diff_stats(vault)?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
let files = parse_status_output(&output.stdout)
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
// Only include markdown files
|
||||
if !entry.relative_path.ends_with(".md") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = FileChangeStatus::from_code(&entry.status_code);
|
||||
let full_path = vault
|
||||
.join(&entry.relative_path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let stats = resolve_diff_stats(
|
||||
vault,
|
||||
Path::new(&entry.relative_path),
|
||||
status,
|
||||
&diff_stats,
|
||||
include_stats,
|
||||
);
|
||||
|
||||
Some(ModifiedFile {
|
||||
path: full_path,
|
||||
relative_path: entry.relative_path,
|
||||
status: status.label().to_string(),
|
||||
added_lines: stats.added_lines,
|
||||
deleted_lines: stats.deleted_lines,
|
||||
binary: stats.binary,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Discard uncommitted changes to a single file.
|
||||
///
|
||||
/// - **Modified / Deleted**: `git checkout -- <file>` restores the last committed version.
|
||||
/// - **Untracked / Added**: the file is removed from disk.
|
||||
///
|
||||
/// The `relative_path` must be relative to `vault_path` (the same format
|
||||
/// returned by [`get_modified_files`]).
|
||||
pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let relative = Path::new(relative_path);
|
||||
let abs = vault.join(relative);
|
||||
|
||||
ensure_path_within_vault(vault, relative, &abs)?;
|
||||
let status_code = load_file_status(vault, relative)?;
|
||||
|
||||
match status_code.as_str() {
|
||||
"??" => {
|
||||
std::fs::remove_file(&abs)
|
||||
.map_err(|e| format!("Failed to delete untracked file: {e}"))?;
|
||||
}
|
||||
_ => {
|
||||
restore_tracked_file(vault, relative)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_command;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
|
||||
fn write_and_commit_markdown(vault: &Path, vp: &str, relative_path: &str, content: &str) {
|
||||
fs::write(vault.join(relative_path), content).unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
}
|
||||
|
||||
fn force_quoted_git_paths(vault: &Path) {
|
||||
git_command()
|
||||
.args(["config", "core.quotePath", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn expect_modified_file(vp: &str, relative_path: &str, status: &str) -> ModifiedFile {
|
||||
let modified = get_modified_files_with_stats(vp).unwrap();
|
||||
let file = modified
|
||||
.iter()
|
||||
.find(|file| file.relative_path == relative_path)
|
||||
.unwrap_or_else(|| panic!("{relative_path} should be reported as {status}"));
|
||||
|
||||
assert_eq!(file.status, status);
|
||||
assert!(file.path.ends_with(relative_path));
|
||||
file.clone()
|
||||
}
|
||||
|
||||
fn expect_changed_file_after(
|
||||
relative_path: &str,
|
||||
status: &str,
|
||||
change: impl FnOnce(&Path, &str),
|
||||
) -> ModifiedFile {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
change(vault, vp);
|
||||
|
||||
expect_modified_file(vp, relative_path, status)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_returns_empty_for_gitless_folder() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
|
||||
|
||||
assert!(get_modified_files(dir.path()).unwrap().is_empty());
|
||||
assert!(get_modified_files_with_stats(dir.path())
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_with_stats() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
// Create and commit a file
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_command()
|
||||
.args(["add", "note.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Add note"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Modify it
|
||||
fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap();
|
||||
// Add an untracked file
|
||||
fs::write(vault.join("new.md"), "# New\n").unwrap();
|
||||
|
||||
let modified = get_modified_files_with_stats(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(modified.len() >= 2);
|
||||
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
|
||||
assert!(statuses.contains(&"modified"));
|
||||
assert!(statuses.contains(&"untracked"));
|
||||
|
||||
let modified_entry = modified
|
||||
.iter()
|
||||
.find(|file| file.relative_path == "note.md")
|
||||
.unwrap();
|
||||
assert!(modified_entry.added_lines.is_some());
|
||||
assert!(!modified_entry.binary);
|
||||
|
||||
let untracked_entry = modified
|
||||
.iter()
|
||||
.find(|file| file.relative_path == "new.md")
|
||||
.unwrap();
|
||||
assert_eq!(untracked_entry.added_lines, Some(1));
|
||||
assert_eq!(untracked_entry.deleted_lines, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_omits_stats_by_default() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_command()
|
||||
.args(["add", "note.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Add note"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap();
|
||||
fs::write(vault.join("new.md"), "# New\n").unwrap();
|
||||
|
||||
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(modified.len() >= 2);
|
||||
assert!(modified.iter().all(|file| file.added_lines.is_none()
|
||||
&& file.deleted_lines.is_none()
|
||||
&& !file.binary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_untracked_in_subdirectory() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
// Create initial commit so git is initialized
|
||||
fs::write(vault.join("init.md"), "# Init\n").unwrap();
|
||||
git_command()
|
||||
.args(["add", "init.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Initial"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Create a new untracked file in a subdirectory (simulates new note creation)
|
||||
fs::create_dir_all(vault.join("note")).unwrap();
|
||||
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
|
||||
|
||||
let modified = get_modified_files_with_stats(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(modified.len(), 1);
|
||||
assert_eq!(modified[0].status, "untracked");
|
||||
assert_eq!(modified[0].relative_path, "note/brand-new.md");
|
||||
assert_eq!(modified[0].added_lines, Some(1));
|
||||
assert!(
|
||||
modified[0].path.ends_with("/note/brand-new.md"),
|
||||
"Full path should end with relative path: {}",
|
||||
modified[0].path
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_preserves_chinese_markdown_path() {
|
||||
let relative_path = "中文笔记.md";
|
||||
|
||||
let file = expect_changed_file_after(relative_path, "modified", |vault, vp| {
|
||||
force_quoted_git_paths(vault);
|
||||
write_and_commit_markdown(vault, vp, relative_path, "# 初始\n");
|
||||
fs::write(vault.join(relative_path), "# 初始\n\n更新\n").unwrap();
|
||||
});
|
||||
assert_eq!(file.added_lines, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_preserves_untracked_markdown_path_with_spaces() {
|
||||
let relative_path = "test note.md";
|
||||
|
||||
let file = expect_changed_file_after(relative_path, "untracked", |vault, vp| {
|
||||
write_and_commit_markdown(vault, vp, "init.md", "# Init\n");
|
||||
fs::write(vault.join(relative_path), "# Test\n").unwrap();
|
||||
});
|
||||
assert_eq!(file.added_lines, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_preserves_modified_markdown_path_with_spaces() {
|
||||
let relative_path = "test note.md";
|
||||
|
||||
let file = expect_changed_file_after(relative_path, "modified", |vault, vp| {
|
||||
write_and_commit_markdown(vault, vp, relative_path, "# Test\n");
|
||||
fs::write(vault.join(relative_path), "# Test\n\nUpdated\n").unwrap();
|
||||
});
|
||||
assert_eq!(file.added_lines, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_preserves_renamed_markdown_path_with_spaces() {
|
||||
let relative_path = "test note.md";
|
||||
|
||||
let file = expect_changed_file_after(relative_path, "renamed", |vault, vp| {
|
||||
write_and_commit_markdown(vault, vp, "alpha.md", "# Alpha\n");
|
||||
git_command()
|
||||
.args(["mv", "alpha.md", relative_path])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
});
|
||||
assert_eq!(file.added_lines, Some(0));
|
||||
assert_eq!(file.deleted_lines, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_flow_modified_files_then_commit_clears() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
// Create and commit initial file
|
||||
fs::write(vault.join("flow.md"), "# Original\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
// Modify the file on disk
|
||||
fs::write(vault.join("flow.md"), "# Modified\n").unwrap();
|
||||
|
||||
// get_modified_files should detect the change
|
||||
let modified = get_modified_files(vp).unwrap();
|
||||
assert!(
|
||||
modified.iter().any(|f| f.relative_path == "flow.md"),
|
||||
"Modified file should be detected after write"
|
||||
);
|
||||
|
||||
// Commit the change
|
||||
let result = git_commit(vp, "update flow").unwrap();
|
||||
assert!(
|
||||
result.contains("1 file changed") || result.contains("flow.md"),
|
||||
"Commit output should reference the changed file: {}",
|
||||
result
|
||||
);
|
||||
|
||||
// After commit, get_modified_files should return empty
|
||||
let after = get_modified_files(vp).unwrap();
|
||||
assert!(
|
||||
after.is_empty(),
|
||||
"No modified files should remain after commit, found: {:?}",
|
||||
after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discard_modified_file() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
write_and_commit_markdown(vault, vp, "note.md", "# Original\n");
|
||||
|
||||
// Modify the file
|
||||
fs::write(vault.join("note.md"), "# Changed\n").unwrap();
|
||||
assert_eq!(get_modified_files(vp).unwrap().len(), 1);
|
||||
|
||||
// Discard
|
||||
discard_file_changes(vp, "note.md").unwrap();
|
||||
|
||||
let content = fs::read_to_string(vault.join("note.md")).unwrap();
|
||||
assert_eq!(content, "# Original\n");
|
||||
assert!(get_modified_files(vp).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discard_untracked_file() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
write_and_commit_markdown(vault, vp, "init.md", "# Init\n");
|
||||
|
||||
// Create an untracked file
|
||||
fs::write(vault.join("new.md"), "# New\n").unwrap();
|
||||
assert!(vault.join("new.md").exists());
|
||||
|
||||
discard_file_changes(vp, "new.md").unwrap();
|
||||
|
||||
assert!(!vault.join("new.md").exists());
|
||||
assert!(get_modified_files(vp).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discard_deleted_file() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
write_and_commit_markdown(vault, vp, "note.md", "# Original\n");
|
||||
|
||||
// Delete the file
|
||||
fs::remove_file(vault.join("note.md")).unwrap();
|
||||
assert!(!vault.join("note.md").exists());
|
||||
|
||||
discard_file_changes(vp, "note.md").unwrap();
|
||||
|
||||
assert!(vault.join("note.md").exists());
|
||||
let content = fs::read_to_string(vault.join("note.md")).unwrap();
|
||||
assert_eq!(content, "# Original\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discard_rejects_path_outside_vault() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
write_and_commit_markdown(vault, vp, "init.md", "# Init\n");
|
||||
|
||||
let result = discard_file_changes(vp, "../../../etc/passwd");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Should reject path outside vault, got: {:?}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
result.unwrap_err().contains("outside the vault"),
|
||||
"Error should mention 'outside the vault'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
use std::path::Path;
|
||||
|
||||
use super::command::{git_output, git_output_result, stdout_text};
|
||||
|
||||
const DETACHED_HEAD_BRANCH: &str = "Detached HEAD";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct UpstreamTarget {
|
||||
pub remote: String,
|
||||
pub branch: String,
|
||||
pub display: String,
|
||||
}
|
||||
|
||||
pub(crate) fn branch_label(vault: &Path) -> Result<String, String> {
|
||||
Ok(current_branch(vault)?.unwrap_or_else(|| DETACHED_HEAD_BRANCH.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn missing_upstream_message(vault: &Path) -> Result<String, String> {
|
||||
let branch = branch_label(vault)?;
|
||||
if branch == DETACHED_HEAD_BRANCH {
|
||||
return Ok(
|
||||
"This vault is in detached HEAD. Check out a branch and configure its upstream before syncing in Tolaria."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(format!(
|
||||
"No upstream branch configured for '{branch}'. Set a tracking branch with external Git tooling, then sync again in Tolaria."
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn sync_target(vault: &Path) -> Result<Option<UpstreamTarget>, String> {
|
||||
let Some(branch_name) = current_branch(vault)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(remote) = config_value(vault, &format!("branch.{branch_name}.remote"))? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(merge_ref) = config_value(vault, &format!("branch.{branch_name}.merge"))? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let branch = merge_ref
|
||||
.strip_prefix("refs/heads/")
|
||||
.unwrap_or(merge_ref.as_str())
|
||||
.to_string();
|
||||
if remote.is_empty() || branch.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(UpstreamTarget {
|
||||
display: format!("{remote}/{branch}"),
|
||||
remote,
|
||||
branch,
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_branch(vault: &Path) -> Result<Option<String>, String> {
|
||||
let output = git_output(vault, &["branch", "--show-current"])
|
||||
.map_err(|e| format!("Failed to get branch: {}", e))?;
|
||||
let branch = stdout_text(&output);
|
||||
if branch.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(branch))
|
||||
}
|
||||
}
|
||||
|
||||
fn config_value(vault: &Path, key: &str) -> Result<Option<String>, String> {
|
||||
let output = git_output_result(vault, &["config", "--get", key])?;
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = stdout_text(&output);
|
||||
if value.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(value))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue