3857 lines
120 KiB
Rust
3857 lines
120 KiB
Rust
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||
|
||
use arboard::Clipboard;
|
||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||
use ignore::gitignore::GitignoreBuilder;
|
||
use notify::{Config as NotifyConfig, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||
use reqwest::blocking::Client;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{json, Value};
|
||
use std::{
|
||
collections::{HashMap, HashSet},
|
||
fs,
|
||
io::Write,
|
||
path::{Path, PathBuf},
|
||
process::{Command, Stdio},
|
||
sync::{
|
||
atomic::{AtomicBool, Ordering},
|
||
Mutex,
|
||
},
|
||
thread,
|
||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||
};
|
||
use tauri::{
|
||
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
|
||
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
|
||
utils::config::Color,
|
||
AppHandle, Emitter, Manager, TitleBarStyle, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
|
||
WindowEvent,
|
||
};
|
||
use tempfile::TempDir;
|
||
|
||
type CommandResult<T> = Result<T, String>;
|
||
|
||
const VALID_THEMES: &[&str] = &["sky", "default", "grey"];
|
||
const EMPTY_TREE_HASH: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
||
const SQUASH_CHUNK_WINDOW_MS: i64 = 2 * 60 * 1000;
|
||
const MAX_SQUASH_PROMPT_CHARS: usize = 25_000;
|
||
const MAX_SQUASH_PROMPT_MESSAGE_CHARS: usize = 400;
|
||
const MAX_SQUASH_NAME_STATUS_CHARS: usize = 6_000;
|
||
const MAX_SQUASH_DIFFSTAT_CHARS: usize = 4_000;
|
||
const MAX_SQUASH_COMMIT_MESSAGE_CHARS: usize = 160;
|
||
const OLLAMA_BASE_URL: &str = "http://127.0.0.1:11434";
|
||
const ROSE_TITLEBAR_COLOR: Color = Color(255, 241, 242, 255);
|
||
|
||
const TAURI_BUILD_IGNORES: &[&str] = &["dist-tauri", "src-tauri/target", "src-tauri/gen"];
|
||
|
||
const MONITOR_DEFAULT_IGNORES: &[&str] = &[
|
||
".git",
|
||
"node_modules",
|
||
".venv",
|
||
"venv",
|
||
"__pycache__",
|
||
".mypy_cache",
|
||
".pytest_cache",
|
||
"dist",
|
||
"dist-tauri",
|
||
"build",
|
||
"out",
|
||
".next",
|
||
".nuxt",
|
||
".turbo",
|
||
".parcel-cache",
|
||
".cache",
|
||
"target",
|
||
"src-tauri/target",
|
||
"src-tauri/gen",
|
||
"coverage",
|
||
"logs",
|
||
"tmp",
|
||
"temp",
|
||
"output",
|
||
"tmp*",
|
||
"*.log",
|
||
"*.tmp",
|
||
"*.swp",
|
||
];
|
||
|
||
const IGNORED_NAMES: &[&str] = &[
|
||
".DS_Store",
|
||
"Thumbs.db",
|
||
"desktop.ini",
|
||
".AppleDouble",
|
||
".LSOverride",
|
||
"Icon\r",
|
||
".git",
|
||
".gitattributes",
|
||
"node_modules",
|
||
"npm-debug.log*",
|
||
"yarn-error.log",
|
||
"yarn-debug.log*",
|
||
"pnpm-debug.log*",
|
||
"package-lock.json",
|
||
"yarn.lock",
|
||
"pnpm-lock.yaml",
|
||
"tsconfig.tsbuildinfo",
|
||
"dist",
|
||
"dist-tauri",
|
||
"build",
|
||
".cache",
|
||
"out",
|
||
".next",
|
||
".turbo",
|
||
".venv",
|
||
"venv",
|
||
"__pycache__",
|
||
"*.py[cod]",
|
||
".mypy_cache",
|
||
".pytest_cache",
|
||
".tox",
|
||
"*.egg-info",
|
||
".coverage",
|
||
"htmlcov",
|
||
".env",
|
||
".env.*",
|
||
"target",
|
||
"*.class",
|
||
"*.jar",
|
||
"*.war",
|
||
"*.ear",
|
||
"*.zip",
|
||
"*.tar.gz",
|
||
"*.rar",
|
||
"*.log",
|
||
"*.iml",
|
||
".idea",
|
||
".project",
|
||
".classpath",
|
||
".settings",
|
||
"*.o",
|
||
"*.obj",
|
||
"*.so",
|
||
"*.dylib",
|
||
"*.dll",
|
||
"*.exe",
|
||
"*.out",
|
||
"*.app",
|
||
"CMakeFiles",
|
||
"CMakeCache.txt",
|
||
"Debug",
|
||
"Release",
|
||
"bin",
|
||
"pkg",
|
||
"vendor",
|
||
"Cargo.lock",
|
||
"*.gem",
|
||
".bundle",
|
||
"vendor/bundle",
|
||
"log",
|
||
"tmp",
|
||
"coverage",
|
||
"composer.lock",
|
||
"*.cache",
|
||
"*.session",
|
||
"obj",
|
||
"TestResults",
|
||
".vs",
|
||
".vscode",
|
||
".history",
|
||
"*.code-workspace",
|
||
"*.sublime-project",
|
||
"*.sublime-workspace",
|
||
"*.swp",
|
||
"*.swo",
|
||
"*.tmp",
|
||
"*.bak",
|
||
"*~",
|
||
"logs",
|
||
"test-results",
|
||
"lcov-report",
|
||
"*.sqlite3",
|
||
"*.sqlite3-journal",
|
||
"*.db",
|
||
"*.db-journal",
|
||
"docker-compose.override.yml",
|
||
".docker",
|
||
"*.pid",
|
||
"*.seed",
|
||
"*.pid.lock",
|
||
".terraform",
|
||
"*.tfstate",
|
||
"*.tfstate.backup",
|
||
".terraform.lock.hcl",
|
||
".serverless",
|
||
".aws-sam",
|
||
".gradle",
|
||
".meteor/local",
|
||
".expo",
|
||
".nuxt",
|
||
".parcel-cache",
|
||
"reports",
|
||
"*.apk",
|
||
"*.aab",
|
||
".android",
|
||
".flutter-plugins",
|
||
".flutter-plugins-dependencies",
|
||
".packages",
|
||
"*.xcworkspace",
|
||
"xcuserdata",
|
||
"DerivedData",
|
||
"*.ipa",
|
||
"*.dSYM",
|
||
"Library",
|
||
"Temp",
|
||
"Obj",
|
||
"Build",
|
||
"Builds",
|
||
"Binaries",
|
||
"DerivedDataCache",
|
||
"Intermediate",
|
||
"Saved",
|
||
"*.lock",
|
||
"*.7z",
|
||
];
|
||
|
||
const CODE_EXTS: &[&str] = &[
|
||
"js", "jsx", "ts", "tsx", "py", "sh", "rb", "pl", "php", "java", "c", "cpp", "h", "cs", "go",
|
||
"rs", "json", "yml", "yaml", "toml", "md", "html", "css", "txt",
|
||
];
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct FolderObj {
|
||
path: String,
|
||
#[serde(default)]
|
||
monitoring: bool,
|
||
#[serde(default)]
|
||
needs_relocation: bool,
|
||
#[serde(default)]
|
||
lines_changed: i64,
|
||
#[serde(default)]
|
||
llm_candidates: Vec<String>,
|
||
#[serde(default)]
|
||
llm_buffer: Vec<String>,
|
||
#[serde(default)]
|
||
first_candidate_birthday: Option<i64>,
|
||
#[serde(default)]
|
||
last_head_hash: Option<String>,
|
||
#[serde(default)]
|
||
rewrite_in_progress: bool,
|
||
#[serde(default)]
|
||
rewrite_started_at: Option<i64>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct StoreData {
|
||
#[serde(default)]
|
||
folders: Vec<FolderObj>,
|
||
#[serde(default)]
|
||
selected: Option<String>,
|
||
#[serde(default = "default_theme")]
|
||
theme: String,
|
||
#[serde(default = "default_true")]
|
||
skymode: bool,
|
||
#[serde(default = "default_true")]
|
||
skip_git_prompt: bool,
|
||
#[serde(default = "default_intelligent_threshold")]
|
||
intelligent_commit_threshold: i64,
|
||
#[serde(default = "default_minutes_threshold")]
|
||
minutes_commit_threshold: i64,
|
||
#[serde(default)]
|
||
autostart: bool,
|
||
#[serde(default = "default_true")]
|
||
close_to_tray: bool,
|
||
#[serde(default)]
|
||
needs_relocation: bool,
|
||
#[serde(default)]
|
||
daily_commit_stats: HashMap<String, i64>,
|
||
#[serde(default)]
|
||
gitea_token: String,
|
||
#[serde(default)]
|
||
rewrite_in_progress: bool,
|
||
#[serde(default)]
|
||
rewrite_started_at: Option<i64>,
|
||
#[serde(default)]
|
||
llm_buffer: Vec<String>,
|
||
#[serde(default)]
|
||
commit_model: Option<String>,
|
||
#[serde(default)]
|
||
readme_model: Option<String>,
|
||
#[serde(default)]
|
||
author: Option<String>,
|
||
#[serde(default)]
|
||
license: Option<String>,
|
||
}
|
||
|
||
impl Default for StoreData {
|
||
fn default() -> Self {
|
||
Self {
|
||
folders: Vec::new(),
|
||
selected: None,
|
||
theme: default_theme(),
|
||
skymode: true,
|
||
skip_git_prompt: true,
|
||
intelligent_commit_threshold: default_intelligent_threshold(),
|
||
minutes_commit_threshold: default_minutes_threshold(),
|
||
autostart: false,
|
||
close_to_tray: true,
|
||
needs_relocation: false,
|
||
daily_commit_stats: HashMap::new(),
|
||
gitea_token: String::new(),
|
||
rewrite_in_progress: false,
|
||
rewrite_started_at: None,
|
||
llm_buffer: Vec::new(),
|
||
commit_model: None,
|
||
readme_model: None,
|
||
author: None,
|
||
license: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn default_theme() -> String {
|
||
"sky".to_string()
|
||
}
|
||
|
||
fn default_true() -> bool {
|
||
true
|
||
}
|
||
|
||
fn default_intelligent_threshold() -> i64 {
|
||
20
|
||
}
|
||
|
||
fn default_minutes_threshold() -> i64 {
|
||
5
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct CommitPage {
|
||
head: Option<String>,
|
||
commits: Vec<CommitSummary>,
|
||
total: usize,
|
||
page: usize,
|
||
page_size: usize,
|
||
pages: usize,
|
||
pending_rewrite_count: usize,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct CommitSummary {
|
||
hash: String,
|
||
date: String,
|
||
message: String,
|
||
needs_rewrite: bool,
|
||
can_reword: bool,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct TreeNode {
|
||
name: String,
|
||
#[serde(rename = "type")]
|
||
node_type: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
children: Option<Vec<TreeNode>>,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct PendingChanges {
|
||
names: HashSet<String>,
|
||
paths: HashSet<PathBuf>,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
enum MenuAction {
|
||
TrayToggle(String),
|
||
TrayRemove(String),
|
||
TrayAdd,
|
||
TrayOpen,
|
||
TrayStartAll,
|
||
TrayStopAll,
|
||
Quit,
|
||
Settings,
|
||
ContextOpen(PathBuf),
|
||
ContextCopy(PathBuf),
|
||
ContextGitignore { root: PathBuf, rel: String },
|
||
}
|
||
|
||
struct AppState {
|
||
store: Mutex<StoreData>,
|
||
store_path: PathBuf,
|
||
watchers: Mutex<HashMap<String, RecommendedWatcher>>,
|
||
pending: Mutex<HashMap<String, PendingChanges>>,
|
||
active: Mutex<HashSet<String>>,
|
||
menu_actions: Mutex<HashMap<String, MenuAction>>,
|
||
quitting: AtomicBool,
|
||
tray: Mutex<Option<TrayIcon>>,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct CommandOutput {
|
||
stdout: String,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct GitStatus {
|
||
not_added: Vec<String>,
|
||
created: Vec<String>,
|
||
modified: Vec<String>,
|
||
deleted: Vec<String>,
|
||
renamed: Vec<(String, String)>,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct SquashCommit {
|
||
hash: String,
|
||
tree: String,
|
||
parents: Vec<String>,
|
||
timestamp_ms: i64,
|
||
author_name: String,
|
||
author_email: String,
|
||
author_date: String,
|
||
committer_name: String,
|
||
committer_email: String,
|
||
committer_date: String,
|
||
message: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct SquashPlanEntry {
|
||
commits: Vec<SquashCommit>,
|
||
message: String,
|
||
}
|
||
|
||
fn now_ms() -> i64 {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_millis() as i64
|
||
}
|
||
|
||
fn debug(msg: impl AsRef<str>) {
|
||
println!("[DEBUG {}] {}", now_ms(), msg.as_ref());
|
||
}
|
||
|
||
fn store_path() -> PathBuf {
|
||
let base = dirs::data_dir()
|
||
.or_else(dirs::config_dir)
|
||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||
base.join("Auto-Git").join("config.json")
|
||
}
|
||
|
||
fn load_store(path: &Path) -> StoreData {
|
||
let mut store = fs::read_to_string(path)
|
||
.ok()
|
||
.and_then(|raw| serde_json::from_str::<StoreData>(&raw).ok())
|
||
.unwrap_or_default();
|
||
normalize_store(&mut store);
|
||
store
|
||
}
|
||
|
||
fn normalize_store(store: &mut StoreData) {
|
||
if !VALID_THEMES.contains(&store.theme.as_str()) {
|
||
store.theme = if store.skymode { "sky" } else { "default" }.to_string();
|
||
}
|
||
store.skymode = store.theme == "sky";
|
||
|
||
for folder in &mut store.folders {
|
||
let repo_exists = Path::new(&folder.path).join(".git").exists();
|
||
let path_exists = Path::new(&folder.path).exists();
|
||
folder.needs_relocation = !path_exists;
|
||
if !repo_exists || folder.needs_relocation {
|
||
folder.monitoring = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn save_store(state: &AppState) -> CommandResult<()> {
|
||
let store = state.store.lock().map_err(|e| e.to_string())?.clone();
|
||
if let Some(parent) = state.store_path.parent() {
|
||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||
}
|
||
let data = serde_json::to_vec_pretty(&store).map_err(|e| e.to_string())?;
|
||
fs::write(&state.store_path, data).map_err(|e| e.to_string())
|
||
}
|
||
|
||
fn run_process(
|
||
program: &str,
|
||
args: &[String],
|
||
cwd: Option<&Path>,
|
||
env: Option<&HashMap<String, String>>,
|
||
input: Option<&str>,
|
||
) -> CommandResult<CommandOutput> {
|
||
let mut cmd = Command::new(program);
|
||
cmd.args(args);
|
||
if let Some(cwd) = cwd {
|
||
cmd.current_dir(cwd);
|
||
}
|
||
if let Some(env) = env {
|
||
cmd.envs(env);
|
||
}
|
||
if input.is_some() {
|
||
cmd.stdin(Stdio::piped());
|
||
}
|
||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||
let mut child = cmd.spawn().map_err(|e| e.to_string())?;
|
||
if let Some(input) = input {
|
||
if let Some(stdin) = child.stdin.as_mut() {
|
||
stdin
|
||
.write_all(input.as_bytes())
|
||
.map_err(|e| e.to_string())?;
|
||
}
|
||
}
|
||
let output = child.wait_with_output().map_err(|e| e.to_string())?;
|
||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||
if output.status.success() {
|
||
Ok(CommandOutput { stdout })
|
||
} else {
|
||
Err(if stderr.trim().is_empty() {
|
||
stdout.trim().to_string()
|
||
} else {
|
||
stderr.trim().to_string()
|
||
})
|
||
}
|
||
}
|
||
|
||
fn run_git(repo: &str, args: &[&str]) -> CommandResult<String> {
|
||
let owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||
Ok(run_process("git", &owned, Some(Path::new(repo)), None, None)?.stdout)
|
||
}
|
||
|
||
fn run_git_owned(
|
||
repo: &str,
|
||
args: &[String],
|
||
env: Option<&HashMap<String, String>>,
|
||
input: Option<&str>,
|
||
) -> CommandResult<String> {
|
||
Ok(run_process("git", args, Some(Path::new(repo)), env, input)?.stdout)
|
||
}
|
||
|
||
fn git_status(repo: &str) -> CommandResult<GitStatus> {
|
||
let raw = run_git(repo, &["status", "--porcelain"])?;
|
||
let mut status = GitStatus::default();
|
||
for line in raw.lines() {
|
||
if line.len() < 3 {
|
||
continue;
|
||
}
|
||
let code = &line[0..2];
|
||
let path = line[3..].trim().to_string();
|
||
if code.contains('?') {
|
||
status.not_added.push(path);
|
||
} else if code.contains('R') {
|
||
if let Some((from, to)) = path.split_once(" -> ") {
|
||
status.renamed.push((from.to_string(), to.to_string()));
|
||
}
|
||
} else if code.contains('A') {
|
||
status.created.push(path);
|
||
} else if code.contains('D') {
|
||
status.deleted.push(path);
|
||
} else if code.contains('M') {
|
||
status.modified.push(path);
|
||
}
|
||
}
|
||
Ok(status)
|
||
}
|
||
|
||
fn has_status_changes(status: &GitStatus) -> bool {
|
||
!status.not_added.is_empty()
|
||
|| !status.created.is_empty()
|
||
|| !status.modified.is_empty()
|
||
|| !status.deleted.is_empty()
|
||
|| !status.renamed.is_empty()
|
||
}
|
||
|
||
fn build_commit_message_from_status(status: &GitStatus, prefix: &str) -> String {
|
||
let mut changes = Vec::new();
|
||
for f in &status.not_added {
|
||
changes.push(format!("[add] {f}"));
|
||
}
|
||
for f in &status.created {
|
||
changes.push(format!("[add] {f}"));
|
||
}
|
||
for f in &status.modified {
|
||
changes.push(format!("[change] {f}"));
|
||
}
|
||
for f in &status.deleted {
|
||
changes.push(format!("[unlink] {f}"));
|
||
}
|
||
for (from, to) in &status.renamed {
|
||
changes.push(format!("[rename] {from} -> {to}"));
|
||
}
|
||
format!("{prefix}\n {}", changes.join("\n "))
|
||
}
|
||
|
||
fn is_rebase_in_progress(repo_path: &str) -> bool {
|
||
let git_dir = Path::new(repo_path).join(".git");
|
||
git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists()
|
||
}
|
||
|
||
fn is_git_repo_path(folder_path: &str) -> bool {
|
||
Path::new(folder_path).join(".git").exists()
|
||
}
|
||
|
||
fn ensure_gitignore_defaults(folder_path: &str) -> CommandResult<()> {
|
||
let gitignore_path = Path::new(folder_path).join(".gitignore");
|
||
let mut existing = HashSet::new();
|
||
if gitignore_path.exists() {
|
||
for line in fs::read_to_string(&gitignore_path)
|
||
.map_err(|e| e.to_string())?
|
||
.lines()
|
||
{
|
||
let trimmed = line.trim();
|
||
if !trimmed.is_empty() {
|
||
existing.insert(trimmed.to_string());
|
||
}
|
||
}
|
||
}
|
||
let mut changed = !gitignore_path.exists();
|
||
for entry in MONITOR_DEFAULT_IGNORES {
|
||
if existing.insert((*entry).to_string()) {
|
||
changed = true;
|
||
}
|
||
}
|
||
if changed {
|
||
let mut lines: Vec<_> = existing.into_iter().collect();
|
||
lines.sort();
|
||
fs::write(gitignore_path, lines.join("\n") + "\n").map_err(|e| e.to_string())?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn init_git_repo_internal(folder: &str) -> CommandResult<()> {
|
||
if !Path::new(folder).join(".git").exists() {
|
||
run_git(folder, &["init"])?;
|
||
ensure_gitignore_defaults(folder)?;
|
||
update_gitignore_from_existing_project(folder)?;
|
||
run_git(folder, &["add", "-A"])?;
|
||
run_git(folder, &["commit", "--allow-empty", "-m", "initial commit"])?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn short_hash(hash: &str) -> String {
|
||
hash.chars().take(7).collect()
|
||
}
|
||
|
||
fn is_auto_git_message(message: &str) -> bool {
|
||
message.trim_start().starts_with("auto-git")
|
||
}
|
||
|
||
fn resolve_commit_hash(repo_path: &str, hash: &str) -> Option<String> {
|
||
run_git(
|
||
repo_path,
|
||
&["rev-parse", "--verify", &format!("{hash}^{{commit}}")],
|
||
)
|
||
.ok()
|
||
.map(|raw| raw.trim().to_string())
|
||
.filter(|resolved| !resolved.is_empty())
|
||
}
|
||
|
||
fn is_commit_on_current_history(repo_path: &str, hash: &str) -> bool {
|
||
run_git(repo_path, &["merge-base", "--is-ancestor", hash, "HEAD"]).is_ok()
|
||
}
|
||
|
||
fn pending_rewrite_hashes(repo_path: &str, queued_hashes: &[String]) -> Vec<String> {
|
||
let queued: HashSet<String> = queued_hashes
|
||
.iter()
|
||
.filter_map(|hash| resolve_commit_hash(repo_path, hash))
|
||
.filter(|hash| is_commit_on_current_history(repo_path, hash))
|
||
.collect();
|
||
let raw = run_git(repo_path, &["log", "--format=%H%x1f%s"]).unwrap_or_default();
|
||
raw.lines()
|
||
.filter_map(|line| {
|
||
let (hash, message) = line.split_once('\x1f')?;
|
||
if is_auto_git_message(message) || queued.contains(hash) {
|
||
Some(hash.to_string())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn truncate_text(text: impl AsRef<str>, max_chars: usize) -> String {
|
||
let normalized = text.as_ref().trim();
|
||
if normalized.chars().count() <= max_chars {
|
||
return normalized.to_string();
|
||
}
|
||
let mut out: String = normalized
|
||
.chars()
|
||
.take(max_chars.saturating_sub(1))
|
||
.collect();
|
||
out = out.trim_end().to_string();
|
||
out.push('…');
|
||
out
|
||
}
|
||
|
||
fn normalize_single_line(text: impl AsRef<str>) -> String {
|
||
text.as_ref()
|
||
.replace("```json", "")
|
||
.replace("```markdown", "")
|
||
.replace("```", "")
|
||
.split_whitespace()
|
||
.collect::<Vec<_>>()
|
||
.join(" ")
|
||
.trim()
|
||
.to_string()
|
||
}
|
||
|
||
fn truncate_prompt_block(text: impl AsRef<str>, max_chars: usize) -> String {
|
||
let normalized = text.as_ref().trim();
|
||
if normalized.is_empty() {
|
||
return String::new();
|
||
}
|
||
if normalized.chars().count() <= max_chars {
|
||
return normalized.to_string();
|
||
}
|
||
let mut out: String = normalized.chars().take(max_chars).collect();
|
||
out = out.trim_end().to_string();
|
||
out.push_str("\n…");
|
||
out
|
||
}
|
||
|
||
fn emit(app: &AppHandle, event: &str, payload: impl Serialize + Clone) {
|
||
if let Err(err) = app.emit(event, payload) {
|
||
eprintln!("[AutoGit] failed to emit {event}: {err}");
|
||
}
|
||
}
|
||
|
||
fn ensure_ollama_running() -> CommandResult<()> {
|
||
let client = Client::builder()
|
||
.timeout(Duration::from_millis(700))
|
||
.build()
|
||
.map_err(|e| e.to_string())?;
|
||
if client.get(OLLAMA_BASE_URL).send().is_ok() {
|
||
return Ok(());
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
{
|
||
if let Ok(output) = run_process(
|
||
"lsof",
|
||
&["-i".into(), ":11434".into(), "-t".into()],
|
||
None,
|
||
None,
|
||
None,
|
||
) {
|
||
for pid in output.stdout.lines().filter(|line| !line.trim().is_empty()) {
|
||
let _ = run_process("kill", &["-9".into(), pid.trim().into()], None, None, None);
|
||
}
|
||
}
|
||
}
|
||
|
||
let _ = Command::new("ollama")
|
||
.arg("serve")
|
||
.stdin(Stdio::null())
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.spawn()
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
for _ in 0..10 {
|
||
thread::sleep(Duration::from_millis(500));
|
||
if client.get(OLLAMA_BASE_URL).send().is_ok() {
|
||
return Ok(());
|
||
}
|
||
}
|
||
Err("[AutoGit] ollama serve could not be reached after 5 seconds".to_string())
|
||
}
|
||
|
||
fn stream_ollama(
|
||
prompt: &str,
|
||
model: &str,
|
||
temperature: f64,
|
||
app: &AppHandle,
|
||
) -> CommandResult<String> {
|
||
ensure_ollama_running()?;
|
||
emit(app, "cat-begin", ());
|
||
|
||
let client = Client::builder()
|
||
.timeout(Duration::from_secs(120))
|
||
.build()
|
||
.map_err(|e| e.to_string())?;
|
||
let mut response = client
|
||
.post(format!("{OLLAMA_BASE_URL}/api/generate"))
|
||
.json(&json!({
|
||
"model": model,
|
||
"prompt": prompt,
|
||
"stream": true,
|
||
"options": { "temperature": temperature }
|
||
}))
|
||
.send()
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
if !response.status().is_success() {
|
||
emit(app, "cat-end", ());
|
||
return Err(format!("Ollama request failed: {}", response.status()));
|
||
}
|
||
|
||
let mut raw = String::new();
|
||
response
|
||
.read_to_string(&mut raw)
|
||
.map_err(|e| e.to_string())?;
|
||
let mut full_output = String::new();
|
||
for line in raw.lines().filter(|line| !line.trim().is_empty()) {
|
||
if let Ok(obj) = serde_json::from_str::<Value>(line) {
|
||
if let Some(chunk) = obj.get("response").and_then(Value::as_str) {
|
||
full_output.push_str(chunk);
|
||
emit(app, "cat-chunk", chunk.to_string());
|
||
}
|
||
}
|
||
}
|
||
emit(app, "cat-end", ());
|
||
Ok(full_output)
|
||
}
|
||
|
||
trait ReadToString {
|
||
fn read_to_string(&mut self, out: &mut String) -> std::io::Result<usize>;
|
||
}
|
||
|
||
impl ReadToString for reqwest::blocking::Response {
|
||
fn read_to_string(&mut self, out: &mut String) -> std::io::Result<usize> {
|
||
use std::io::Read;
|
||
let mut buf = Vec::new();
|
||
let len = self.read_to_end(&mut buf)?;
|
||
out.push_str(&String::from_utf8_lossy(&buf));
|
||
Ok(len)
|
||
}
|
||
}
|
||
|
||
fn parse_llm_commit_messages(raw_output: &str) -> CommandResult<HashMap<String, String>> {
|
||
let cleaned = raw_output
|
||
.trim()
|
||
.trim_start_matches("```json")
|
||
.trim_start_matches("```")
|
||
.trim_end_matches("```")
|
||
.trim();
|
||
if cleaned.starts_with('{') {
|
||
let obj: Value = serde_json::from_str(cleaned)
|
||
.map_err(|e| format!("Could not parse LLM output: {e}\n{raw_output}"))?;
|
||
let mut map = HashMap::new();
|
||
if let Some(entries) = obj.as_object() {
|
||
for (hash, message) in entries {
|
||
if let Some(message) = message.as_str() {
|
||
map.insert(hash.clone(), message.to_string());
|
||
}
|
||
}
|
||
}
|
||
return Ok(map);
|
||
}
|
||
if cleaned.starts_with('[') {
|
||
let arr: Vec<Value> = serde_json::from_str(cleaned)
|
||
.map_err(|e| format!("Could not parse LLM output: {e}\n{raw_output}"))?;
|
||
let mut map = HashMap::new();
|
||
for item in arr {
|
||
if let (Some(hash), Some(message)) = (
|
||
item.get("commit").and_then(Value::as_str),
|
||
item.get("newMessage")
|
||
.or_else(|| item.get("new_message"))
|
||
.and_then(Value::as_str),
|
||
) {
|
||
map.insert(hash.to_string(), message.to_string());
|
||
}
|
||
}
|
||
return Ok(map);
|
||
}
|
||
Err(format!("Could not parse LLM output:\n{raw_output}"))
|
||
}
|
||
|
||
fn validate_llm_commit_messages(
|
||
parsed: HashMap<String, String>,
|
||
hashes: &[String],
|
||
) -> CommandResult<HashMap<String, String>> {
|
||
if parsed.len() != hashes.len() {
|
||
return Err(format!(
|
||
"LLM returned {} commit messages; expected {}.",
|
||
parsed.len(),
|
||
hashes.len()
|
||
));
|
||
}
|
||
|
||
let mut validated = HashMap::new();
|
||
for hash in hashes {
|
||
let short = short_hash(hash);
|
||
let message = parsed
|
||
.get(hash)
|
||
.or_else(|| parsed.get(&short))
|
||
.ok_or_else(|| format!("LLM response did not contain commit {short}."))?
|
||
.trim();
|
||
if message.is_empty() {
|
||
return Err(format!("LLM returned an empty message for commit {short}."));
|
||
}
|
||
if message.lines().count() != 1 {
|
||
return Err(format!(
|
||
"LLM returned a multi-line message for commit {short}."
|
||
));
|
||
}
|
||
if is_auto_git_message(message) {
|
||
return Err(format!(
|
||
"LLM did not replace the generic message for commit {short}."
|
||
));
|
||
}
|
||
validated.insert(hash.clone(), message.to_string());
|
||
}
|
||
Ok(validated)
|
||
}
|
||
|
||
fn generate_llm_message_for_commit(
|
||
app: &AppHandle,
|
||
folder_path: &str,
|
||
hash: &str,
|
||
model: &str,
|
||
) -> CommandResult<String> {
|
||
let hashes = vec![hash.to_string()];
|
||
let prompt = generate_llm_commit_prompt(folder_path, &hashes)?;
|
||
let llm_raw = stream_ollama(&prompt, model, 0.3, app)?;
|
||
let parsed = parse_llm_commit_messages(&llm_raw)?;
|
||
let validated = validate_llm_commit_messages(parsed, &hashes)?;
|
||
validated
|
||
.get(hash)
|
||
.cloned()
|
||
.ok_or_else(|| format!("No validated message was returned for {}.", short_hash(hash)))
|
||
}
|
||
|
||
fn get_commits_for_llm(folder_path: &str, hashes: &[String]) -> CommandResult<Vec<Value>> {
|
||
let mut commits = Vec::new();
|
||
for hash in hashes {
|
||
let diff = run_git(folder_path, &["diff", &format!("{hash}^!")])?;
|
||
let msg = run_git(folder_path, &["show", "-s", "--format=%B", hash])?;
|
||
commits.push(json!({
|
||
"hash": short_hash(hash),
|
||
"message": msg.trim(),
|
||
"diff": diff
|
||
}));
|
||
}
|
||
Ok(commits)
|
||
}
|
||
|
||
fn generate_llm_commit_prompt(folder_path: &str, hashes: &[String]) -> CommandResult<String> {
|
||
let commits = get_commits_for_llm(folder_path, hashes)?;
|
||
let prompt = format!(
|
||
r#"Analyze the following git commits. For each commit, generate a concise commit message summarizing the actual change.
|
||
- ONLY output a JSON object mapping each commit hash to its new message.
|
||
- Do NOT add any explanations, greetings, or extra text.
|
||
|
||
Example Output:
|
||
{{
|
||
"1a2b3c4": "Fix bug in user registration",
|
||
"2b3c4d5": "Refactor login logic"
|
||
}}
|
||
|
||
COMMITS (as JSON):
|
||
|
||
{}"#,
|
||
serde_json::to_string_pretty(&commits).map_err(|e| e.to_string())?
|
||
);
|
||
if prompt.len() > 200_000 {
|
||
return Err(format!(
|
||
"LLM prompt too large ({} chars) for {folder_path}",
|
||
prompt.len()
|
||
));
|
||
}
|
||
Ok(prompt)
|
||
}
|
||
|
||
fn reword_commits_sequentially(
|
||
repo_path: &str,
|
||
commit_message_map: &HashMap<String, String>,
|
||
hashes: &[String],
|
||
) -> CommandResult<()> {
|
||
let status = git_status(repo_path)?;
|
||
let mut stashed = false;
|
||
if has_status_changes(&status) {
|
||
let _ = run_git(
|
||
repo_path,
|
||
&["stash", "push", "--include-untracked", "--keep-index"],
|
||
);
|
||
stashed = true;
|
||
}
|
||
|
||
let all_raw = run_git(repo_path, &["log", "--format=%H"])?;
|
||
let all_commits: Vec<String> = all_raw.lines().map(|s| s.to_string()).collect();
|
||
let full_hashes = resolve_reword_hashes_newest_first(&all_commits, hashes);
|
||
|
||
let temp_dir = TempDir::new().map_err(|e| e.to_string())?;
|
||
let sequence_path = temp_dir.path().join(if cfg!(windows) {
|
||
"sequence-editor.cmd"
|
||
} else {
|
||
"sequence-editor.sh"
|
||
});
|
||
let message_path = temp_dir.path().join(if cfg!(windows) {
|
||
"message-editor.cmd"
|
||
} else {
|
||
"message-editor.sh"
|
||
});
|
||
|
||
if cfg!(windows) {
|
||
fs::write(
|
||
&sequence_path,
|
||
"@echo off\r\npowershell -NoProfile -Command \"(Get-Content %1) -replace '^pick ', 'reword ' | Set-Content %1\"\r\n",
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
} else {
|
||
fs::write(
|
||
&sequence_path,
|
||
"#!/bin/sh\nsed -i.bak '1s/^pick /reword /' \"$1\"\n",
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
set_executable(&sequence_path)?;
|
||
}
|
||
|
||
for full_hash in full_hashes {
|
||
let short = short_hash(&full_hash);
|
||
let new_msg = commit_message_map
|
||
.get(&full_hash)
|
||
.or_else(|| commit_message_map.get(&short))
|
||
.cloned();
|
||
let Some(new_msg) = new_msg else {
|
||
continue;
|
||
};
|
||
|
||
let _ = run_git(repo_path, &["rebase", "--abort"]);
|
||
let msg_file = temp_dir.path().join("commit-message.txt");
|
||
fs::write(&msg_file, new_msg.trim().to_string() + "\n").map_err(|e| e.to_string())?;
|
||
if cfg!(windows) {
|
||
fs::write(
|
||
&message_path,
|
||
format!(
|
||
"@echo off\r\ncopy /Y \"{}\" %1 >NUL\r\n",
|
||
msg_file.display()
|
||
),
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
} else {
|
||
fs::write(
|
||
&message_path,
|
||
format!("#!/bin/sh\ncat \"{}\" > \"$1\"\n", msg_file.display()),
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
set_executable(&message_path)?;
|
||
}
|
||
|
||
let mut env = HashMap::new();
|
||
env.insert(
|
||
"GIT_SEQUENCE_EDITOR".to_string(),
|
||
sequence_path.to_string_lossy().to_string(),
|
||
);
|
||
env.insert(
|
||
"GIT_EDITOR".to_string(),
|
||
message_path.to_string_lossy().to_string(),
|
||
);
|
||
run_git_owned(
|
||
repo_path,
|
||
&["rebase".into(), "-i".into(), format!("{full_hash}^")],
|
||
Some(&env),
|
||
None,
|
||
)?;
|
||
}
|
||
|
||
if stashed {
|
||
let _ = run_git(repo_path, &["stash", "pop"]);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn resolve_reword_hashes_newest_first(all_commits: &[String], hashes: &[String]) -> Vec<String> {
|
||
let mut full_hashes: Vec<String> = hashes
|
||
.iter()
|
||
.filter_map(|h| all_commits.iter().find(|full| full.starts_with(h)).cloned())
|
||
.collect();
|
||
full_hashes.sort_by_key(|h| {
|
||
all_commits
|
||
.iter()
|
||
.position(|full| full == h)
|
||
.unwrap_or(usize::MAX)
|
||
});
|
||
full_hashes
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn set_executable(path: &Path) -> CommandResult<()> {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let mut permissions = fs::metadata(path).map_err(|e| e.to_string())?.permissions();
|
||
permissions.set_mode(0o755);
|
||
fs::set_permissions(path, permissions).map_err(|e| e.to_string())
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
fn set_executable(_path: &Path) -> CommandResult<()> {
|
||
Ok(())
|
||
}
|
||
|
||
fn run_llm_commit_rewrite(app: AppHandle, folder_path: String, force: bool) -> CommandResult<()> {
|
||
let state = app.state::<AppState>();
|
||
let (hashes, original_birthday) = {
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
let Some(folder) = store.folders.iter_mut().find(|f| f.path == folder_path) else {
|
||
return Ok(());
|
||
};
|
||
if folder.needs_relocation {
|
||
return Ok(());
|
||
}
|
||
if folder.llm_candidates.is_empty() {
|
||
return Ok(());
|
||
}
|
||
if folder.rewrite_in_progress && !force {
|
||
return Ok(());
|
||
}
|
||
if folder.rewrite_in_progress && force {
|
||
folder.rewrite_in_progress = false;
|
||
folder.rewrite_started_at = None;
|
||
}
|
||
let hashes = folder.llm_candidates.clone();
|
||
let original_birthday = folder.first_candidate_birthday;
|
||
folder.rewrite_in_progress = true;
|
||
folder.rewrite_started_at = Some(now_ms());
|
||
(hashes, original_birthday)
|
||
};
|
||
save_store(&state)?;
|
||
|
||
let mut error: Option<String> = None;
|
||
let result = (|| -> CommandResult<()> {
|
||
let prompt = generate_llm_commit_prompt(&folder_path, &hashes)?;
|
||
let model = {
|
||
let store = state.store.lock().map_err(|e| e.to_string())?;
|
||
store
|
||
.commit_model
|
||
.clone()
|
||
.unwrap_or_else(|| "qwen2.5-coder:7b".to_string())
|
||
};
|
||
let llm_raw = stream_ollama(&prompt, &model, 0.3, &app)?;
|
||
let message_map =
|
||
validate_llm_commit_messages(parse_llm_commit_messages(&llm_raw)?, &hashes)?;
|
||
reword_commits_sequentially(&folder_path, &message_map, &hashes)?;
|
||
emit(&app, "repo-updated", folder_path.clone());
|
||
Ok(())
|
||
})();
|
||
|
||
if let Err(err) = result {
|
||
error = Some(err.clone());
|
||
eprintln!("[runLLMCommitRewrite] Rewrite failed: {err}");
|
||
let fallback = (|| -> CommandResult<()> {
|
||
let mut map = HashMap::new();
|
||
for h in &hashes {
|
||
let msg = run_git(&folder_path, &["show", "-s", "--format=%B", h])?;
|
||
map.insert(h.clone(), msg.trim().to_string());
|
||
}
|
||
reword_commits_sequentially(&folder_path, &map, &hashes)?;
|
||
emit(&app, "repo-updated", folder_path.clone());
|
||
Ok(())
|
||
})();
|
||
if let Err(fallback_err) = fallback {
|
||
eprintln!("[runLLMCommitRewrite] Recovery failed: {fallback_err}");
|
||
}
|
||
}
|
||
|
||
let retry_candidates = if error.is_some() {
|
||
let mut candidates = hashes.clone();
|
||
let buffer = {
|
||
let store = state.store.lock().map_err(|e| e.to_string())?;
|
||
store
|
||
.folders
|
||
.iter()
|
||
.find(|f| f.path == folder_path)
|
||
.map(|f| f.llm_buffer.clone())
|
||
.unwrap_or_default()
|
||
};
|
||
candidates.extend(buffer);
|
||
pending_rewrite_hashes(&folder_path, &candidates)
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
if let Some(folder) = store.folders.iter_mut().find(|f| f.path == folder_path) {
|
||
let buffer = folder.llm_buffer.clone();
|
||
if error.is_some() {
|
||
folder.llm_candidates = retry_candidates;
|
||
folder.first_candidate_birthday = original_birthday.or_else(|| {
|
||
if folder.llm_candidates.is_empty() {
|
||
None
|
||
} else {
|
||
Some(now_ms())
|
||
}
|
||
});
|
||
} else {
|
||
folder.llm_candidates = buffer;
|
||
folder.first_candidate_birthday = if folder.llm_candidates.is_empty() {
|
||
None
|
||
} else {
|
||
Some(now_ms())
|
||
};
|
||
folder.lines_changed = 0;
|
||
}
|
||
folder.llm_buffer.clear();
|
||
folder.rewrite_in_progress = false;
|
||
folder.rewrite_started_at = None;
|
||
}
|
||
}
|
||
save_store(&state)?;
|
||
emit(&app, "repo-updated", folder_path.clone());
|
||
|
||
if let Some(err) = error {
|
||
return Err(err);
|
||
}
|
||
|
||
if let Ok(status) = git_status(&folder_path) {
|
||
if has_status_changes(&status) {
|
||
let msg = build_commit_message_from_status(&status, "auto-git: ");
|
||
let _ = auto_commit(app, folder_path, msg);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn parse_numstat_added_deleted(line: &str) -> i64 {
|
||
let cols: Vec<_> = line.split_whitespace().collect();
|
||
if cols.len() < 2 {
|
||
return 0;
|
||
}
|
||
let added = cols[0].parse::<i64>().unwrap_or(0);
|
||
let deleted = cols[1].parse::<i64>().unwrap_or(0);
|
||
added + deleted
|
||
}
|
||
|
||
fn auto_commit(app: AppHandle, folder_path: String, message: String) -> CommandResult<bool> {
|
||
let state = app.state::<AppState>();
|
||
let rewrite_active = {
|
||
let store = state.store.lock().map_err(|e| e.to_string())?;
|
||
store
|
||
.folders
|
||
.iter()
|
||
.find(|f| f.path == folder_path)
|
||
.map(|f| f.rewrite_in_progress)
|
||
.unwrap_or(false)
|
||
};
|
||
if rewrite_active {
|
||
return Ok(false);
|
||
}
|
||
|
||
if is_rebase_in_progress(&folder_path) {
|
||
let _ = run_git(&folder_path, &["rebase", "--abort"]);
|
||
}
|
||
|
||
let status = git_status(&folder_path)?;
|
||
if !has_status_changes(&status) {
|
||
return Ok(false);
|
||
}
|
||
|
||
let current_branch = run_git(&folder_path, &["rev-parse", "--abbrev-ref", "HEAD"])
|
||
.ok()
|
||
.map(|s| s.trim().to_string());
|
||
|
||
if current_branch.as_deref() == Some("HEAD") || current_branch.is_none() {
|
||
let head_commit = run_git(&folder_path, &["rev-parse", "HEAD"])?
|
||
.trim()
|
||
.to_string();
|
||
let master_commit = run_git(&folder_path, &["rev-parse", "refs/heads/master"])
|
||
.ok()
|
||
.map(|s| s.trim().to_string());
|
||
match master_commit {
|
||
Some(master) if master == head_commit => {
|
||
run_git(&folder_path, &["checkout", "master"])?;
|
||
}
|
||
Some(_) => {
|
||
let backup = format!("backup-master-{}", now_ms());
|
||
run_git(&folder_path, &["branch", "-m", "master", &backup])?;
|
||
run_git(&folder_path, &["checkout", "-b", "master"])?;
|
||
}
|
||
None => {
|
||
run_git(&folder_path, &["checkout", "-b", "master"])?;
|
||
}
|
||
}
|
||
}
|
||
|
||
let diff_output = run_git(&folder_path, &["diff", "--numstat"])?;
|
||
let changed_lines: i64 = diff_output.lines().map(parse_numstat_added_deleted).sum();
|
||
run_git(&folder_path, &["add", "-A"])?;
|
||
run_git(&folder_path, &["commit", "-m", &message])?;
|
||
let new_head = run_git(&folder_path, &["rev-parse", "HEAD"])?
|
||
.trim()
|
||
.to_string();
|
||
|
||
let should_rewrite = {
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
let threshold = store.intelligent_commit_threshold;
|
||
let today = current_date_string();
|
||
*store.daily_commit_stats.entry(today).or_insert(0) += 1;
|
||
let Some(folder) = store.folders.iter_mut().find(|f| f.path == folder_path) else {
|
||
return Ok(true);
|
||
};
|
||
folder.lines_changed += changed_lines;
|
||
if folder.rewrite_in_progress {
|
||
folder.llm_buffer.push(new_head.clone());
|
||
} else {
|
||
folder.llm_candidates.push(new_head.clone());
|
||
if folder.llm_candidates.len() == 1 {
|
||
folder.first_candidate_birthday = Some(now_ms());
|
||
}
|
||
}
|
||
folder.last_head_hash = Some(new_head);
|
||
!folder.rewrite_in_progress && folder.lines_changed >= threshold
|
||
};
|
||
save_store(&state)?;
|
||
|
||
if should_rewrite {
|
||
let app_clone = app.clone();
|
||
let folder_clone = folder_path.clone();
|
||
thread::spawn(move || {
|
||
if let Err(err) = run_llm_commit_rewrite(app_clone, folder_clone, false) {
|
||
eprintln!("[autoCommit] rewrite failed: {err}");
|
||
}
|
||
});
|
||
}
|
||
Ok(true)
|
||
}
|
||
|
||
fn current_date_string() -> String {
|
||
let output = Command::new("date")
|
||
.arg("+%Y-%m-%d")
|
||
.output()
|
||
.ok()
|
||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||
.unwrap_or_else(|| "1970-01-01".to_string());
|
||
output.trim().to_string()
|
||
}
|
||
|
||
fn ignored_globset() -> GlobSet {
|
||
let mut builder = GlobSetBuilder::new();
|
||
for pat in IGNORED_NAMES.iter().chain(MONITOR_DEFAULT_IGNORES.iter()) {
|
||
let pattern = if pat.contains('/') || pat.contains('*') {
|
||
(*pat).to_string()
|
||
} else {
|
||
format!("**/{pat}")
|
||
};
|
||
if let Ok(glob) = Glob::new(&pattern) {
|
||
builder.add(glob);
|
||
}
|
||
}
|
||
builder
|
||
.build()
|
||
.unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
|
||
}
|
||
|
||
fn normalized_relative_path(folder_path: &str, path: &Path) -> Option<String> {
|
||
let rel = path.strip_prefix(folder_path).unwrap_or(path);
|
||
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
||
let rel_str = rel_str
|
||
.trim_start_matches("./")
|
||
.trim_matches('/')
|
||
.to_string();
|
||
if rel_str.is_empty() {
|
||
None
|
||
} else {
|
||
Some(rel_str)
|
||
}
|
||
}
|
||
|
||
fn matches_repo_relative_path(rel_path: &str, pattern: &str) -> bool {
|
||
let pattern = pattern.trim_matches('/');
|
||
rel_path == pattern || rel_path.starts_with(&format!("{pattern}/"))
|
||
}
|
||
|
||
fn tauri_build_ignore_for_path(folder_path: &str, path: &Path) -> Option<&'static str> {
|
||
let rel_path = normalized_relative_path(folder_path, path)?;
|
||
TAURI_BUILD_IGNORES.iter().copied().find(|pattern| {
|
||
if pattern.contains('/') {
|
||
matches_repo_relative_path(&rel_path, pattern)
|
||
} else {
|
||
rel_path.split('/').any(|part| part == *pattern)
|
||
}
|
||
})
|
||
}
|
||
|
||
fn is_default_ignored(folder_path: &str, path: &Path) -> bool {
|
||
let rel = path.strip_prefix(folder_path).unwrap_or(path);
|
||
let rel_str = rel.to_string_lossy();
|
||
if rel_str.is_empty() {
|
||
return false;
|
||
}
|
||
if tauri_build_ignore_for_path(folder_path, path).is_some() {
|
||
return true;
|
||
}
|
||
if rel.components().any(|c| {
|
||
let c = c.as_os_str().to_string_lossy();
|
||
c == ".git" || c == "node_modules" || c == "dist-tauri" || c == "target"
|
||
}) {
|
||
return true;
|
||
}
|
||
ignored_globset().is_match(rel)
|
||
}
|
||
|
||
fn gitignore_ignores(folder_path: &str, path: &Path) -> bool {
|
||
let mut builder = GitignoreBuilder::new(folder_path);
|
||
let gitignore = Path::new(folder_path).join(".gitignore");
|
||
if gitignore.exists() {
|
||
let _ = builder.add(gitignore);
|
||
}
|
||
let Ok(ig) = builder.build() else {
|
||
return false;
|
||
};
|
||
let rel = path.strip_prefix(folder_path).unwrap_or(path);
|
||
ig.matched_path_or_any_parents(rel, path.is_dir())
|
||
.is_ignore()
|
||
}
|
||
|
||
fn should_ignore_path(folder_path: &str, path: &Path) -> bool {
|
||
is_default_ignored(folder_path, path) || gitignore_ignores(folder_path, path)
|
||
}
|
||
|
||
fn ensure_in_gitignore(folder_path: &str, pattern: &str) -> CommandResult<bool> {
|
||
let gitignore_path = Path::new(folder_path).join(".gitignore");
|
||
let mut lines = if gitignore_path.exists() {
|
||
fs::read_to_string(&gitignore_path)
|
||
.map_err(|e| e.to_string())?
|
||
.lines()
|
||
.map(|s| s.to_string())
|
||
.collect::<Vec<_>>()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
if lines.iter().any(|line| line.trim() == pattern) {
|
||
return Ok(false);
|
||
}
|
||
lines.push(pattern.to_string());
|
||
fs::write(gitignore_path, lines.join("\n") + "\n").map_err(|e| e.to_string())?;
|
||
Ok(true)
|
||
}
|
||
|
||
fn file_name_matches_ignore(name: &str, pattern: &str) -> bool {
|
||
let normalized = pattern.trim_end_matches('/');
|
||
if normalized.contains('*') {
|
||
Glob::new(normalized)
|
||
.ok()
|
||
.and_then(|g| g.compile_matcher().is_match(name).then_some(()))
|
||
.is_some()
|
||
} else {
|
||
name == normalized
|
||
}
|
||
}
|
||
|
||
fn update_gitignore_from_existing_project(folder_path: &str) -> CommandResult<()> {
|
||
let mut stack = vec![PathBuf::from(folder_path)];
|
||
let mut patterns = HashSet::new();
|
||
|
||
while let Some(current) = stack.pop() {
|
||
let entries = fs::read_dir(¤t).map_err(|e| e.to_string())?;
|
||
for entry in entries {
|
||
let entry = entry.map_err(|e| e.to_string())?;
|
||
let path = entry.path();
|
||
let file_type = entry.file_type().map_err(|e| e.to_string())?;
|
||
let name = entry.file_name().to_string_lossy().to_string();
|
||
|
||
if let Some(pattern) = tauri_build_ignore_for_path(folder_path, &path) {
|
||
patterns.insert(pattern.to_string());
|
||
}
|
||
let matched_patterns = IGNORED_NAMES
|
||
.iter()
|
||
.copied()
|
||
.filter(|pattern| file_name_matches_ignore(&name, pattern))
|
||
.collect::<Vec<_>>();
|
||
for pattern in &matched_patterns {
|
||
patterns.insert((*pattern).to_string());
|
||
}
|
||
|
||
if file_type.is_dir()
|
||
&& matched_patterns.is_empty()
|
||
&& !should_ignore_path(folder_path, &path)
|
||
{
|
||
stack.push(path);
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut patterns = patterns.into_iter().collect::<Vec<_>>();
|
||
patterns.sort();
|
||
for pattern in patterns {
|
||
ensure_in_gitignore(folder_path, &pattern)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn exceeds_file_limit(folder_path: &str, limit: usize) -> bool {
|
||
let mut count = 0usize;
|
||
let mut stack = vec![PathBuf::from(folder_path)];
|
||
while let Some(current) = stack.pop() {
|
||
let Ok(entries) = fs::read_dir(¤t) else {
|
||
continue;
|
||
};
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if should_ignore_path(folder_path, &path) {
|
||
continue;
|
||
}
|
||
if path.is_dir() {
|
||
stack.push(path);
|
||
} else {
|
||
count += 1;
|
||
if count > limit {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
fn start_monitoring_watcher(
|
||
app: AppHandle,
|
||
folder_path: String,
|
||
skip_initial_check: bool,
|
||
) -> CommandResult<()> {
|
||
let state = app.state::<AppState>();
|
||
if state
|
||
.watchers
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.contains_key(&folder_path)
|
||
{
|
||
return Ok(());
|
||
}
|
||
if !is_git_repo_path(&folder_path) {
|
||
return Ok(());
|
||
}
|
||
if exceeds_file_limit(&folder_path, 20_000) {
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
for folder in &mut store.folders {
|
||
if folder.path == folder_path {
|
||
folder.monitoring = false;
|
||
}
|
||
}
|
||
}
|
||
save_store(&state)?;
|
||
emit(
|
||
&app,
|
||
"monitoring-error",
|
||
json!({ "path": folder_path, "code": "TOO_MANY_FILES" }),
|
||
);
|
||
return Ok(());
|
||
}
|
||
|
||
let app_for_watcher = app.clone();
|
||
let watched_folder = folder_path.clone();
|
||
let mut watcher = RecommendedWatcher::new(
|
||
move |result: notify::Result<notify::Event>| match result {
|
||
Ok(event) => {
|
||
if !matches!(
|
||
event.kind,
|
||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
|
||
) {
|
||
return;
|
||
}
|
||
let mut relevant = false;
|
||
let state = app_for_watcher.state::<AppState>();
|
||
{
|
||
let mut pending = match state.pending.lock() {
|
||
Ok(guard) => guard,
|
||
Err(_) => return,
|
||
};
|
||
let entry = pending.entry(watched_folder.clone()).or_default();
|
||
for path in event.paths {
|
||
let is_tauri_build_path =
|
||
tauri_build_ignore_for_path(&watched_folder, &path).is_some();
|
||
if !is_tauri_build_path && should_ignore_path(&watched_folder, &path) {
|
||
continue;
|
||
}
|
||
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
|
||
entry.names.insert(name.to_string());
|
||
}
|
||
entry.paths.insert(path);
|
||
relevant = true;
|
||
}
|
||
}
|
||
if relevant {
|
||
schedule_pending_processing(app_for_watcher.clone(), watched_folder.clone());
|
||
}
|
||
}
|
||
Err(err) => {
|
||
eprintln!("[MONITOR] watcher error for {watched_folder}: {err}");
|
||
}
|
||
},
|
||
NotifyConfig::default(),
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
watcher
|
||
.watch(Path::new(&folder_path), RecursiveMode::Recursive)
|
||
.map_err(|e| e.to_string())?;
|
||
state
|
||
.watchers
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.insert(folder_path.clone(), watcher);
|
||
|
||
if !skip_initial_check {
|
||
schedule_pending_processing(app.clone(), folder_path.clone());
|
||
}
|
||
debug(format!("[MONITOR] Watcher active for {folder_path}"));
|
||
Ok(())
|
||
}
|
||
|
||
fn stop_monitoring_watcher(state: &AppState, folder_path: &str) {
|
||
if let Ok(mut watchers) = state.watchers.lock() {
|
||
watchers.remove(folder_path);
|
||
}
|
||
}
|
||
|
||
fn schedule_pending_processing(app: AppHandle, folder_path: String) {
|
||
let state = app.state::<AppState>();
|
||
{
|
||
let mut active = match state.active.lock() {
|
||
Ok(guard) => guard,
|
||
Err(_) => return,
|
||
};
|
||
if active.contains(&folder_path) {
|
||
return;
|
||
}
|
||
active.insert(folder_path.clone());
|
||
}
|
||
thread::spawn(move || {
|
||
thread::sleep(Duration::from_millis(550));
|
||
if let Err(err) = process_pending_changes(app.clone(), folder_path.clone()) {
|
||
eprintln!("[MONITOR] process pending failed for {folder_path}: {err}");
|
||
}
|
||
let state = app.state::<AppState>();
|
||
{
|
||
if let Ok(mut active) = state.active.lock() {
|
||
active.remove(&folder_path);
|
||
};
|
||
}
|
||
});
|
||
}
|
||
|
||
fn process_pending_changes(app: AppHandle, folder_path: String) -> CommandResult<()> {
|
||
let state = app.state::<AppState>();
|
||
let pending = {
|
||
let mut pending_map = state.pending.lock().map_err(|e| e.to_string())?;
|
||
pending_map.remove(&folder_path).unwrap_or_default()
|
||
};
|
||
let PendingChanges { names, paths } = pending;
|
||
|
||
let mut tauri_patterns = HashSet::new();
|
||
for path in &paths {
|
||
if let Some(pattern) = tauri_build_ignore_for_path(&folder_path, path) {
|
||
tauri_patterns.insert(pattern);
|
||
}
|
||
}
|
||
for pattern in &tauri_patterns {
|
||
let _ = ensure_in_gitignore(&folder_path, pattern);
|
||
}
|
||
|
||
let has_tauri_target = tauri_patterns.contains(&"src-tauri/target");
|
||
|
||
for name in names {
|
||
if has_tauri_target && name == "target" {
|
||
continue;
|
||
}
|
||
for pattern in IGNORED_NAMES {
|
||
if file_name_matches_ignore(&name, pattern) {
|
||
let _ = ensure_in_gitignore(&folder_path, pattern);
|
||
}
|
||
}
|
||
}
|
||
|
||
if is_git_repo_path(&folder_path) {
|
||
let status = git_status(&folder_path)?;
|
||
if has_status_changes(&status) {
|
||
let msg = build_commit_message_from_status(&status, "auto-git: ");
|
||
if auto_commit(app.clone(), folder_path.clone(), msg)? {
|
||
emit(&app, "repo-updated", folder_path);
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn add_folder_by_path_internal(
|
||
app: &AppHandle,
|
||
state: &AppState,
|
||
new_folder: String,
|
||
) -> CommandResult<Vec<FolderObj>> {
|
||
let is_repo = is_git_repo_path(&new_folder);
|
||
let last_head_hash = if is_repo {
|
||
run_git(&new_folder, &["rev-parse", "HEAD"])
|
||
.ok()
|
||
.map(|s| s.trim().to_string())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
if let Some(folder) = store.folders.iter_mut().find(|f| f.path == new_folder) {
|
||
folder.last_head_hash = last_head_hash;
|
||
folder.monitoring = folder.monitoring && is_repo;
|
||
folder.llm_buffer = folder.llm_buffer.clone();
|
||
folder.llm_candidates = folder.llm_candidates.clone();
|
||
} else {
|
||
store.folders.push(FolderObj {
|
||
path: new_folder.clone(),
|
||
monitoring: is_repo,
|
||
needs_relocation: false,
|
||
lines_changed: 0,
|
||
llm_candidates: Vec::new(),
|
||
llm_buffer: Vec::new(),
|
||
first_candidate_birthday: None,
|
||
last_head_hash,
|
||
rewrite_in_progress: false,
|
||
rewrite_started_at: None,
|
||
});
|
||
}
|
||
store.selected = Some(new_folder.clone());
|
||
}
|
||
save_store(state)?;
|
||
if is_repo {
|
||
start_monitoring_watcher(app.clone(), new_folder, false)?;
|
||
}
|
||
update_tray_menu(app)?;
|
||
Ok(state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.folders
|
||
.clone())
|
||
}
|
||
|
||
fn update_folders_listener(app: &AppHandle) -> CommandResult<()> {
|
||
let state = app.state::<AppState>();
|
||
let mut changed_folders = Vec::new();
|
||
let now = now_ms();
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
let minutes_threshold = store.minutes_commit_threshold;
|
||
for folder in &mut store.folders {
|
||
if folder.rewrite_in_progress {
|
||
let in_rebase = is_rebase_in_progress(&folder.path);
|
||
let started = folder.rewrite_started_at.unwrap_or(0);
|
||
if !in_rebase && now - started > 2 * 60 * 1000 {
|
||
let mut merged = folder.llm_candidates.clone();
|
||
merged.extend(folder.llm_buffer.clone());
|
||
folder.rewrite_in_progress = false;
|
||
folder.rewrite_started_at = None;
|
||
folder.llm_buffer.clear();
|
||
folder.llm_candidates = merged;
|
||
folder.first_candidate_birthday = if folder.llm_candidates.is_empty() {
|
||
None
|
||
} else {
|
||
Some(now_ms())
|
||
};
|
||
}
|
||
}
|
||
if let Some(birthday) = folder.first_candidate_birthday {
|
||
if !folder.rewrite_in_progress
|
||
&& ((now - birthday) as f64 / 1000.0 / 60.0) >= minutes_threshold as f64
|
||
{
|
||
let app_clone = app.clone();
|
||
let folder_path = folder.path.clone();
|
||
thread::spawn(move || {
|
||
let _ = run_llm_commit_rewrite(app_clone, folder_path, false);
|
||
});
|
||
}
|
||
}
|
||
|
||
let was_relocated = folder.needs_relocation;
|
||
let now_exists = Path::new(&folder.path).exists();
|
||
if was_relocated && now_exists {
|
||
let hash_found = folder
|
||
.last_head_hash
|
||
.as_ref()
|
||
.and_then(|hash| run_git(&folder.path, &["branch", "--contains", hash]).ok())
|
||
.map(|raw| !raw.trim().is_empty())
|
||
.unwrap_or(false);
|
||
if hash_found {
|
||
folder.needs_relocation = false;
|
||
changed_folders.push(folder.clone());
|
||
} else {
|
||
folder.needs_relocation = true;
|
||
}
|
||
} else if !now_exists && !was_relocated {
|
||
folder.needs_relocation = true;
|
||
folder.monitoring = false;
|
||
changed_folders.push(folder.clone());
|
||
}
|
||
}
|
||
}
|
||
|
||
if !changed_folders.is_empty() {
|
||
save_store(&state)?;
|
||
for folder in changed_folders {
|
||
if folder.needs_relocation {
|
||
stop_monitoring_watcher(&state, &folder.path);
|
||
}
|
||
emit(app, "folders-location-updated", folder);
|
||
}
|
||
update_tray_menu(app)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn build_app_menu(app: &tauri::App) -> tauri::Result<()> {
|
||
let settings = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?;
|
||
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||
let app_menu = Submenu::with_items(app, "Auto-Git", true, &[&settings, &quit])?;
|
||
let undo = PredefinedMenuItem::undo(app, None)?;
|
||
let redo = PredefinedMenuItem::redo(app, None)?;
|
||
let separator = PredefinedMenuItem::separator(app)?;
|
||
let cut = PredefinedMenuItem::cut(app, None)?;
|
||
let copy = PredefinedMenuItem::copy(app, None)?;
|
||
let paste = PredefinedMenuItem::paste(app, None)?;
|
||
let select_all = PredefinedMenuItem::select_all(app, None)?;
|
||
let edit_submenu = Submenu::with_items(
|
||
app,
|
||
"Edit",
|
||
true,
|
||
&[&undo, &redo, &separator, &cut, ©, &paste, &select_all],
|
||
)?;
|
||
let menu = Menu::with_items(app, &[&app_menu, &edit_submenu])?;
|
||
app.set_menu(menu)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn menu_id(prefix: &str, path: &str) -> String {
|
||
format!("{prefix}_{}", URL_SAFE_NO_PAD.encode(path))
|
||
}
|
||
|
||
fn update_tray_menu(app: &AppHandle) -> CommandResult<()> {
|
||
let state = app.state::<AppState>();
|
||
let folders = state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.folders
|
||
.clone();
|
||
let mut actions = HashMap::new();
|
||
actions.insert("tray_open".to_string(), MenuAction::TrayOpen);
|
||
actions.insert("tray_add".to_string(), MenuAction::TrayAdd);
|
||
actions.insert("tray_start_all".to_string(), MenuAction::TrayStartAll);
|
||
actions.insert("tray_stop_all".to_string(), MenuAction::TrayStopAll);
|
||
actions.insert("tray_quit".to_string(), MenuAction::Quit);
|
||
|
||
let mut items: Vec<Box<dyn tauri::menu::IsMenuItem<tauri::Wry>>> = Vec::new();
|
||
items.push(Box::new(
|
||
MenuItem::with_id(app, "tray_open", "Auto-Git öffnen", true, None::<&str>)
|
||
.map_err(|e| e.to_string())?,
|
||
));
|
||
items.push(Box::new(
|
||
PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?,
|
||
));
|
||
|
||
for folder in &folders {
|
||
let label = format!(
|
||
"{} {}",
|
||
if folder.monitoring { "●" } else { "○" },
|
||
Path::new(&folder.path)
|
||
.file_name()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or(&folder.path)
|
||
);
|
||
let toggle_id = menu_id("tray_toggle", &folder.path);
|
||
let remove_id = menu_id("tray_remove", &folder.path);
|
||
actions.insert(
|
||
toggle_id.clone(),
|
||
MenuAction::TrayToggle(folder.path.clone()),
|
||
);
|
||
actions.insert(
|
||
remove_id.clone(),
|
||
MenuAction::TrayRemove(folder.path.clone()),
|
||
);
|
||
let toggle = MenuItem::with_id(
|
||
app,
|
||
toggle_id,
|
||
if folder.monitoring {
|
||
"Monitoring stoppen"
|
||
} else {
|
||
"Monitoring starten"
|
||
},
|
||
!folder.needs_relocation,
|
||
None::<&str>,
|
||
)
|
||
.map_err(|e| e.to_string())?;
|
||
let remove = MenuItem::with_id(app, remove_id, "Ordner entfernen", true, None::<&str>)
|
||
.map_err(|e| e.to_string())?;
|
||
let submenu = Submenu::with_items(app, label, true, &[&toggle, &remove])
|
||
.map_err(|e| e.to_string())?;
|
||
items.push(Box::new(submenu));
|
||
}
|
||
|
||
items.push(Box::new(
|
||
PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?,
|
||
));
|
||
items.push(Box::new(
|
||
MenuItem::with_id(
|
||
app,
|
||
"tray_add",
|
||
"Neuen Ordner hinzufügen",
|
||
true,
|
||
None::<&str>,
|
||
)
|
||
.map_err(|e| e.to_string())?,
|
||
));
|
||
items.push(Box::new(
|
||
MenuItem::with_id(
|
||
app,
|
||
"tray_start_all",
|
||
"Alle Monitorings starten",
|
||
true,
|
||
None::<&str>,
|
||
)
|
||
.map_err(|e| e.to_string())?,
|
||
));
|
||
items.push(Box::new(
|
||
MenuItem::with_id(
|
||
app,
|
||
"tray_stop_all",
|
||
"Alle Monitorings stoppen",
|
||
true,
|
||
None::<&str>,
|
||
)
|
||
.map_err(|e| e.to_string())?,
|
||
));
|
||
items.push(Box::new(
|
||
PredefinedMenuItem::separator(app).map_err(|e| e.to_string())?,
|
||
));
|
||
items.push(Box::new(
|
||
MenuItem::with_id(app, "tray_quit", "Beenden", true, None::<&str>)
|
||
.map_err(|e| e.to_string())?,
|
||
));
|
||
|
||
let refs: Vec<&dyn tauri::menu::IsMenuItem<tauri::Wry>> =
|
||
items.iter().map(|item| item.as_ref()).collect();
|
||
let menu = Menu::with_items(app, &refs).map_err(|e| e.to_string())?;
|
||
*state.menu_actions.lock().map_err(|e| e.to_string())? = actions;
|
||
|
||
let mut tray_guard = state.tray.lock().map_err(|e| e.to_string())?;
|
||
if let Some(tray) = tray_guard.as_ref() {
|
||
tray.set_menu(Some(menu)).map_err(|e| e.to_string())?;
|
||
} else {
|
||
let mut builder = TrayIconBuilder::with_id("main-tray")
|
||
.tooltip("Auto-Git läuft im Hintergrund")
|
||
.menu(&menu)
|
||
.show_menu_on_left_click(false);
|
||
if let Some(icon) = app.default_window_icon() {
|
||
builder = builder.icon(icon.clone());
|
||
}
|
||
let tray = builder
|
||
.on_tray_icon_event(|tray: &TrayIcon<tauri::Wry>, event| {
|
||
if let TrayIconEvent::Click {
|
||
button: MouseButton::Left,
|
||
button_state: MouseButtonState::Up,
|
||
..
|
||
} = event
|
||
{
|
||
if let Some(app) = tray.app_handle().get_webview_window("main") {
|
||
let _ = app.show();
|
||
let _ = app.set_focus();
|
||
}
|
||
}
|
||
})
|
||
.build(app)
|
||
.map_err(|e| e.to_string())?;
|
||
*tray_guard = Some(tray);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn open_settings_window(app: &AppHandle) -> CommandResult<()> {
|
||
if let Some(win) = app.get_webview_window("settings") {
|
||
win.show().map_err(|e| e.to_string())?;
|
||
win.set_focus().map_err(|e| e.to_string())?;
|
||
return Ok(());
|
||
}
|
||
let builder =
|
||
WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into()))
|
||
.title("Einstellungen")
|
||
.inner_size(600.0, 500.0)
|
||
.resizable(false)
|
||
.background_color(ROSE_TITLEBAR_COLOR);
|
||
#[cfg(target_os = "macos")]
|
||
let builder = builder
|
||
.title_bar_style(TitleBarStyle::Transparent)
|
||
.hidden_title(true);
|
||
builder.build().map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
fn handle_menu_action(app: &AppHandle, action: MenuAction) {
|
||
let state = app.state::<AppState>();
|
||
match action {
|
||
MenuAction::TrayOpen => {
|
||
if let Some(win) = app.get_webview_window("main") {
|
||
let _ = win.show();
|
||
let _ = win.set_focus();
|
||
}
|
||
}
|
||
MenuAction::TrayToggle(path) => emit(app, "tray-toggle-monitoring", path),
|
||
MenuAction::TrayRemove(path) => emit(app, "tray-remove-folder", path),
|
||
MenuAction::TrayAdd => emit(app, "tray-add-folder", ()),
|
||
MenuAction::TrayStartAll => {
|
||
if let Ok(store) = state.store.lock() {
|
||
for folder in &store.folders {
|
||
if !folder.monitoring && !folder.needs_relocation {
|
||
emit(app, "tray-toggle-monitoring", folder.path.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
MenuAction::TrayStopAll => {
|
||
if let Ok(store) = state.store.lock() {
|
||
for folder in &store.folders {
|
||
if folder.monitoring {
|
||
emit(app, "tray-toggle-monitoring", folder.path.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
MenuAction::Quit => {
|
||
state.quitting.store(true, Ordering::SeqCst);
|
||
app.exit(0);
|
||
}
|
||
MenuAction::Settings => {
|
||
let _ = open_settings_window(app);
|
||
}
|
||
MenuAction::ContextOpen(path) => {
|
||
let _ = open::that(path);
|
||
}
|
||
MenuAction::ContextCopy(path) => {
|
||
if let Ok(mut clipboard) = Clipboard::new() {
|
||
let _ = clipboard.set_text(path.to_string_lossy().to_string());
|
||
}
|
||
}
|
||
MenuAction::ContextGitignore { root, rel } => {
|
||
let gitignore = root.join(".gitignore");
|
||
if let Err(err) = fs::OpenOptions::new()
|
||
.create(true)
|
||
.append(true)
|
||
.open(gitignore)
|
||
.and_then(|mut file| writeln!(file, "\n{rel}"))
|
||
{
|
||
eprintln!("Konnte nicht zu .gitignore hinzufügen: {err}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn get_selected_internal(state: &AppState) -> CommandResult<Option<FolderObj>> {
|
||
let store = state.store.lock().map_err(|e| e.to_string())?;
|
||
Ok(store
|
||
.selected
|
||
.as_ref()
|
||
.and_then(|selected| store.folders.iter().find(|f| &f.path == selected).cloned()))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_selected(state: tauri::State<'_, AppState>) -> CommandResult<Option<FolderObj>> {
|
||
get_selected_internal(&state)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn set_selected(
|
||
state: tauri::State<'_, AppState>,
|
||
folder_obj_or_path: Value,
|
||
) -> CommandResult<Option<FolderObj>> {
|
||
let folder_path = if let Some(path) = folder_obj_or_path.as_str() {
|
||
path.to_string()
|
||
} else {
|
||
folder_obj_or_path
|
||
.get("path")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| "missing folder path".to_string())?
|
||
.to_string()
|
||
};
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
store.selected = Some(folder_path.clone());
|
||
}
|
||
save_store(&state)?;
|
||
get_selected_internal(&state)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_folders(state: tauri::State<'_, AppState>) -> CommandResult<Vec<FolderObj>> {
|
||
Ok(state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.folders
|
||
.clone())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn add_folder(app: AppHandle, state: tauri::State<'_, AppState>) -> CommandResult<Vec<FolderObj>> {
|
||
let Some(path) = rfd::FileDialog::new().pick_folder() else {
|
||
return Ok(state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.folders
|
||
.clone());
|
||
};
|
||
add_folder_by_path_internal(&app, &state, path.to_string_lossy().to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn add_folder_by_path(
|
||
app: AppHandle,
|
||
state: tauri::State<'_, AppState>,
|
||
folder_path: String,
|
||
) -> CommandResult<Vec<FolderObj>> {
|
||
add_folder_by_path_internal(&app, &state, folder_path)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn remove_folder(
|
||
app: AppHandle,
|
||
state: tauri::State<'_, AppState>,
|
||
folder_obj: FolderObj,
|
||
) -> CommandResult<Vec<FolderObj>> {
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
store.folders.retain(|f| f.path != folder_obj.path);
|
||
if store.selected.as_deref() == Some(&folder_obj.path) {
|
||
store.selected = None;
|
||
}
|
||
}
|
||
stop_monitoring_watcher(&state, &folder_obj.path);
|
||
save_store(&state)?;
|
||
update_tray_menu(&app)?;
|
||
Ok(state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.folders
|
||
.clone())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_commit_count(folder_obj: FolderObj) -> CommandResult<usize> {
|
||
if folder_obj.needs_relocation || !Path::new(&folder_obj.path).join(".git").exists() {
|
||
return Ok(0);
|
||
}
|
||
let raw = run_git(&folder_obj.path, &["rev-list", "--all", "--count"]).unwrap_or_default();
|
||
Ok(raw.trim().parse::<usize>().unwrap_or(0))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn has_diffs(folder_obj: FolderObj) -> CommandResult<bool> {
|
||
if folder_obj.needs_relocation || !is_git_repo_path(&folder_obj.path) {
|
||
return Ok(false);
|
||
}
|
||
Ok(has_status_changes(&git_status(&folder_obj.path)?))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn remove_git_folder(folder_obj: FolderObj) -> CommandResult<()> {
|
||
if !folder_obj.needs_relocation {
|
||
let git_dir = Path::new(&folder_obj.path).join(".git");
|
||
if git_dir.exists() {
|
||
fs::remove_dir_all(git_dir).map_err(|e| e.to_string())?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_commits(
|
||
state: tauri::State<'_, AppState>,
|
||
folder_obj: FolderObj,
|
||
page: Option<usize>,
|
||
page_size: Option<usize>,
|
||
) -> CommandResult<CommitPage> {
|
||
let page = page.unwrap_or(1).max(1);
|
||
let page_size = page_size.unwrap_or(50).max(1);
|
||
if folder_obj.needs_relocation || !Path::new(&folder_obj.path).exists() {
|
||
return Ok(CommitPage {
|
||
head: None,
|
||
commits: Vec::new(),
|
||
total: 0,
|
||
page: 1,
|
||
page_size,
|
||
pages: 1,
|
||
pending_rewrite_count: 0,
|
||
});
|
||
}
|
||
let queued_hashes = state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.folders
|
||
.iter()
|
||
.find(|folder| folder.path == folder_obj.path)
|
||
.map(|folder| folder.llm_candidates.clone())
|
||
.unwrap_or_default();
|
||
let queued_full: HashSet<String> = queued_hashes
|
||
.iter()
|
||
.filter_map(|hash| resolve_commit_hash(&folder_obj.path, hash))
|
||
.collect();
|
||
let current_history: HashSet<String> = run_git(&folder_obj.path, &["rev-list", "HEAD"])
|
||
.unwrap_or_default()
|
||
.lines()
|
||
.map(str::to_string)
|
||
.collect();
|
||
let pending_rewrite_count = pending_rewrite_hashes(&folder_obj.path, &queued_hashes).len();
|
||
let total = run_git(&folder_obj.path, &["rev-list", "--all", "--count"])
|
||
.ok()
|
||
.and_then(|raw| raw.trim().parse::<usize>().ok())
|
||
.unwrap_or(0);
|
||
if total == 0 {
|
||
return Ok(CommitPage {
|
||
head: None,
|
||
commits: Vec::new(),
|
||
total: 0,
|
||
page: 1,
|
||
page_size,
|
||
pages: 1,
|
||
pending_rewrite_count: 0,
|
||
});
|
||
}
|
||
let skip = (page - 1) * page_size;
|
||
let raw = run_git(
|
||
&folder_obj.path,
|
||
&[
|
||
"log",
|
||
"--all",
|
||
&format!("--skip={skip}"),
|
||
&format!("--max-count={page_size}"),
|
||
"--date=iso-strict",
|
||
"--format=%H%x1f%aI%x1f%s",
|
||
],
|
||
)
|
||
.unwrap_or_default();
|
||
let commits = raw
|
||
.lines()
|
||
.filter_map(|line| {
|
||
let mut parts = line.splitn(3, '\x1f');
|
||
let hash = parts.next()?;
|
||
let date = parts.next()?;
|
||
let message = parts.next().unwrap_or("");
|
||
Some(CommitSummary {
|
||
hash: short_hash(hash),
|
||
date: date.to_string(),
|
||
message: message.to_string(),
|
||
needs_rewrite: is_auto_git_message(message) || queued_full.contains(hash),
|
||
can_reword: current_history.contains(hash),
|
||
})
|
||
})
|
||
.collect();
|
||
let head = run_git(&folder_obj.path, &["rev-parse", "--verify", "HEAD"])
|
||
.ok()
|
||
.map(|raw| short_hash(raw.trim()));
|
||
let pages = (total + page_size - 1) / page_size;
|
||
Ok(CommitPage {
|
||
head,
|
||
commits,
|
||
total,
|
||
page,
|
||
page_size,
|
||
pages: pages.max(1),
|
||
pending_rewrite_count,
|
||
})
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn diff_commit(folder_obj: FolderObj, hash: String) -> CommandResult<Option<String>> {
|
||
if folder_obj.needs_relocation || !Path::new(&folder_obj.path).exists() {
|
||
return Ok(None);
|
||
}
|
||
Ok(Some(run_git(
|
||
&folder_obj.path,
|
||
&["diff", &format!("{hash}^!")],
|
||
)?))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn revert_commit(folder_obj: FolderObj, hash: String) -> CommandResult<()> {
|
||
if !folder_obj.needs_relocation && Path::new(&folder_obj.path).exists() {
|
||
run_git(&folder_obj.path, &["revert", &hash, "--no-edit"])?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn checkout_commit(folder_obj: FolderObj, hash: String) -> CommandResult<()> {
|
||
if !folder_obj.needs_relocation && Path::new(&folder_obj.path).exists() {
|
||
run_git(&folder_obj.path, &["checkout", &hash, "--force"])?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn snapshot_commit(folder_obj: FolderObj, hash: String) -> CommandResult<Option<String>> {
|
||
if folder_obj.needs_relocation || !Path::new(&folder_obj.path).exists() {
|
||
return Ok(None);
|
||
}
|
||
let Some(out_dir) = rfd::FileDialog::new()
|
||
.set_title("Ordner auswählen zum Speichern des Snapshots")
|
||
.pick_folder()
|
||
else {
|
||
return Ok(None);
|
||
};
|
||
let base = Path::new(&folder_obj.path)
|
||
.file_name()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("snapshot");
|
||
let file_path = out_dir.join(format!("{base}-{hash}.zip"));
|
||
run_process(
|
||
"git",
|
||
&[
|
||
"-C".into(),
|
||
folder_obj.path,
|
||
"archive".into(),
|
||
"--format".into(),
|
||
"zip".into(),
|
||
"--output".into(),
|
||
file_path.to_string_lossy().to_string(),
|
||
hash,
|
||
],
|
||
None,
|
||
None,
|
||
None,
|
||
)?;
|
||
Ok(Some(file_path.to_string_lossy().to_string()))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_theme(state: tauri::State<'_, AppState>) -> CommandResult<String> {
|
||
Ok(state.store.lock().map_err(|e| e.to_string())?.theme.clone())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn set_theme(app: AppHandle, state: tauri::State<'_, AppState>, val: String) -> CommandResult<()> {
|
||
let theme = if VALID_THEMES.contains(&val.as_str()) {
|
||
val
|
||
} else {
|
||
"default".to_string()
|
||
};
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
store.theme = theme.clone();
|
||
store.skymode = theme == "sky";
|
||
}
|
||
save_store(&state)?;
|
||
emit(&app, "theme-changed", theme);
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_skip_git_prompt(state: tauri::State<'_, AppState>) -> CommandResult<bool> {
|
||
Ok(state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.skip_git_prompt)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn set_skip_git_prompt(state: tauri::State<'_, AppState>, val: bool) -> CommandResult<()> {
|
||
state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.skip_git_prompt = val;
|
||
save_store(&state)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_folder_tree(folder_path: String) -> CommandResult<Vec<TreeNode>> {
|
||
fn walk(base: &Path, rel: &Path) -> Vec<TreeNode> {
|
||
let full = base.join(rel);
|
||
let mut nodes = Vec::new();
|
||
let Ok(entries) = fs::read_dir(full) else {
|
||
return nodes;
|
||
};
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
let name = entry.file_name().to_string_lossy().to_string();
|
||
if [
|
||
".DS_Store",
|
||
"node_modules",
|
||
".git",
|
||
"dist",
|
||
"dist-tauri",
|
||
"build",
|
||
".cache",
|
||
"out",
|
||
".venv",
|
||
".mypy_cache",
|
||
"__pycache__",
|
||
"package-lock.json",
|
||
]
|
||
.contains(&name.as_str())
|
||
{
|
||
continue;
|
||
}
|
||
if path.is_dir() {
|
||
nodes.push(TreeNode {
|
||
name: name.clone(),
|
||
node_type: "dir".to_string(),
|
||
children: Some(walk(base, &rel.join(&name))),
|
||
});
|
||
} else {
|
||
nodes.push(TreeNode {
|
||
name,
|
||
node_type: "file".to_string(),
|
||
children: None,
|
||
});
|
||
}
|
||
}
|
||
nodes
|
||
}
|
||
Ok(walk(Path::new(&folder_path), Path::new(".")))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn commit_current_folder(folder_obj: FolderObj, message: Option<String>) -> CommandResult<Value> {
|
||
if folder_obj.needs_relocation || !Path::new(&folder_obj.path).exists() {
|
||
return Ok(json!({}));
|
||
}
|
||
let status = git_status(&folder_obj.path)?;
|
||
if !has_status_changes(&status) {
|
||
return Ok(json!({ "success": false, "error": "Nichts zu committen." }));
|
||
}
|
||
if is_rebase_in_progress(&folder_obj.path) {
|
||
let _ = run_git(&folder_obj.path, &["rebase", "--abort"]);
|
||
}
|
||
run_git(&folder_obj.path, &["add", "-A"])?;
|
||
run_git(
|
||
&folder_obj.path,
|
||
&["commit", "-m", message.as_deref().unwrap_or("test")],
|
||
)?;
|
||
Ok(json!({ "success": true }))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn set_monitoring(
|
||
app: AppHandle,
|
||
state: tauri::State<'_, AppState>,
|
||
folder_path: String,
|
||
mut monitoring: bool,
|
||
) -> CommandResult<bool> {
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
let Some(folder) = store.folders.iter_mut().find(|f| f.path == folder_path) else {
|
||
return Ok(false);
|
||
};
|
||
if folder.needs_relocation {
|
||
return Ok(false);
|
||
}
|
||
if monitoring && !is_git_repo_path(&folder_path) {
|
||
monitoring = false;
|
||
}
|
||
folder.monitoring = monitoring;
|
||
}
|
||
save_store(&state)?;
|
||
if monitoring {
|
||
start_monitoring_watcher(app.clone(), folder_path, false)?;
|
||
} else {
|
||
stop_monitoring_watcher(&state, &folder_path);
|
||
}
|
||
update_tray_menu(&app)?;
|
||
Ok(monitoring)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn ollama_list() -> CommandResult<Value> {
|
||
if let Ok(models) = ollama_list_from_api() {
|
||
return Ok(json!({ "status": "ok", "models": models }));
|
||
}
|
||
|
||
let output = run_process(
|
||
"ollama",
|
||
&["list".into(), "--json".into()],
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
match output {
|
||
Ok(out) => {
|
||
let mut models = Vec::new();
|
||
for line in out.stdout.lines().filter(|l| !l.trim().is_empty()) {
|
||
if let Ok(value) = serde_json::from_str::<Value>(line) {
|
||
models.push(value);
|
||
}
|
||
}
|
||
if !models.is_empty() {
|
||
return Ok(json!({ "status": "ok", "models": models }));
|
||
}
|
||
parse_ollama_list_plain()
|
||
}
|
||
Err(_) => parse_ollama_list_plain(),
|
||
}
|
||
}
|
||
|
||
fn ollama_list_from_api() -> CommandResult<Vec<Value>> {
|
||
let client = Client::builder()
|
||
.timeout(Duration::from_secs(2))
|
||
.build()
|
||
.map_err(|e| e.to_string())?;
|
||
let response = client
|
||
.get(format!("{OLLAMA_BASE_URL}/api/tags"))
|
||
.send()
|
||
.map_err(|e| e.to_string())?;
|
||
let status = response.status();
|
||
if !status.is_success() {
|
||
return Err(format!("Ollama tags request failed: {status}"));
|
||
}
|
||
let payload: Value = response.json().map_err(|e| e.to_string())?;
|
||
Ok(payload
|
||
.get("models")
|
||
.and_then(Value::as_array)
|
||
.cloned()
|
||
.unwrap_or_default())
|
||
}
|
||
|
||
fn parse_ollama_list_plain() -> CommandResult<Value> {
|
||
match run_process("ollama", &["list".into()], None, None, None) {
|
||
Ok(out) => {
|
||
let models: Vec<Value> = out
|
||
.stdout
|
||
.lines()
|
||
.skip(1)
|
||
.filter_map(|line| {
|
||
let name = line.split_whitespace().next()?;
|
||
Some(json!({ "name": name }))
|
||
})
|
||
.collect();
|
||
Ok(json!({ "status": "ok", "models": models }))
|
||
}
|
||
Err(err) if err.contains("No such file") || err.contains("not found") => {
|
||
Ok(json!({ "status": "no-cli" }))
|
||
}
|
||
Err(err) => Ok(json!({ "status": "error", "msg": err })),
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn ollama_pull(model: String) -> CommandResult<Value> {
|
||
if let Ok(value) = ollama_pull_from_api(&model) {
|
||
return Ok(value);
|
||
}
|
||
|
||
match run_process("ollama", &["pull".into(), model], None, None, None) {
|
||
Ok(out) => Ok(json!({ "status": "ok", "msg": out.stdout })),
|
||
Err(err) => Ok(json!({ "status": "error", "msg": err })),
|
||
}
|
||
}
|
||
|
||
fn ollama_pull_from_api(model: &str) -> CommandResult<Value> {
|
||
let client = Client::builder()
|
||
.timeout(Duration::from_secs(30 * 60))
|
||
.build()
|
||
.map_err(|e| e.to_string())?;
|
||
let response = client
|
||
.post(format!("{OLLAMA_BASE_URL}/api/pull"))
|
||
.json(&json!({ "name": model, "stream": false }))
|
||
.send()
|
||
.map_err(|e| e.to_string())?;
|
||
let status = response.status();
|
||
if !status.is_success() {
|
||
return Err(format!("Ollama pull request failed: {status}"));
|
||
}
|
||
let payload: Value = response.json().unwrap_or_else(|_| json!({}));
|
||
Ok(json!({
|
||
"status": "ok",
|
||
"msg": payload.get("status").and_then(Value::as_str).unwrap_or("model pulled")
|
||
}))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_commit_model(state: tauri::State<'_, AppState>) -> CommandResult<String> {
|
||
Ok(state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.commit_model
|
||
.clone()
|
||
.unwrap_or_else(|| "qwen2.5-coder:7b".to_string()))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn set_commit_model(state: tauri::State<'_, AppState>, val: String) -> CommandResult<()> {
|
||
state.store.lock().map_err(|e| e.to_string())?.commit_model = Some(val);
|
||
save_store(&state)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_readme_model(state: tauri::State<'_, AppState>) -> CommandResult<String> {
|
||
Ok(state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.readme_model
|
||
.clone()
|
||
.unwrap_or_else(|| "qwen2.5-coder:32b".to_string()))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn set_readme_model(state: tauri::State<'_, AppState>, val: String) -> CommandResult<()> {
|
||
state.store.lock().map_err(|e| e.to_string())?.readme_model = Some(val);
|
||
save_store(&state)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_intelligent_commit_threshold(state: tauri::State<'_, AppState>) -> CommandResult<i64> {
|
||
Ok(state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.intelligent_commit_threshold)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn set_intelligent_commit_threshold(
|
||
state: tauri::State<'_, AppState>,
|
||
value: i64,
|
||
) -> CommandResult<()> {
|
||
state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.intelligent_commit_threshold = value;
|
||
save_store(&state)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_minutes_commit_threshold(state: tauri::State<'_, AppState>) -> CommandResult<i64> {
|
||
Ok(state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.minutes_commit_threshold)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn set_minutes_commit_threshold(
|
||
state: tauri::State<'_, AppState>,
|
||
value: i64,
|
||
) -> CommandResult<()> {
|
||
state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.minutes_commit_threshold = value;
|
||
save_store(&state)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_autostart(state: tauri::State<'_, AppState>) -> CommandResult<bool> {
|
||
Ok(state.store.lock().map_err(|e| e.to_string())?.autostart)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn set_autostart(state: tauri::State<'_, AppState>, enabled: bool) -> CommandResult<()> {
|
||
state.store.lock().map_err(|e| e.to_string())?.autostart = enabled;
|
||
save_store(&state)?;
|
||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||
let auto = auto_launch::AutoLaunchBuilder::new()
|
||
.set_app_name("Auto-Git")
|
||
.set_app_path(exe.to_string_lossy().as_ref())
|
||
.build()
|
||
.map_err(|e| e.to_string())?;
|
||
if enabled {
|
||
auto.enable().map_err(|e| e.to_string())?;
|
||
} else {
|
||
auto.disable().map_err(|e| e.to_string())?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_close_to_tray(state: tauri::State<'_, AppState>) -> CommandResult<bool> {
|
||
Ok(state.store.lock().map_err(|e| e.to_string())?.close_to_tray)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn set_close_to_tray(state: tauri::State<'_, AppState>, val: bool) -> CommandResult<()> {
|
||
state.store.lock().map_err(|e| e.to_string())?.close_to_tray = val;
|
||
save_store(&state)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn close_settings(app: AppHandle) -> CommandResult<()> {
|
||
if let Some(win) = app.get_webview_window("settings") {
|
||
win.close().map_err(|e| e.to_string())?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn is_git_repo(folder_path: String) -> CommandResult<bool> {
|
||
Ok(is_git_repo_path(&folder_path))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn init_repo(
|
||
app: AppHandle,
|
||
state: tauri::State<'_, AppState>,
|
||
folder_path: String,
|
||
) -> CommandResult<Value> {
|
||
match init_git_repo_internal(&folder_path) {
|
||
Ok(()) => {
|
||
let last_head_hash = run_git(&folder_path, &["rev-parse", "HEAD"])
|
||
.ok()
|
||
.map(|s| s.trim().to_string());
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
for folder in &mut store.folders {
|
||
if folder.path == folder_path {
|
||
folder.monitoring = true;
|
||
folder.needs_relocation = false;
|
||
folder.last_head_hash = last_head_hash.clone();
|
||
}
|
||
}
|
||
}
|
||
save_store(&state)?;
|
||
start_monitoring_watcher(app.clone(), folder_path, true)?;
|
||
update_tray_menu(&app)?;
|
||
Ok(json!({ "success": true }))
|
||
}
|
||
Err(err) => Ok(json!({ "success": false, "error": err })),
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn relocate_folder(
|
||
state: tauri::State<'_, AppState>,
|
||
old_path: String,
|
||
new_path: String,
|
||
) -> CommandResult<Option<FolderObj>> {
|
||
let mut updated = None;
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
for folder in &mut store.folders {
|
||
if folder.path == old_path {
|
||
folder.path = new_path.clone();
|
||
folder.needs_relocation = false;
|
||
updated = Some(folder.clone());
|
||
}
|
||
}
|
||
}
|
||
save_store(&state)?;
|
||
Ok(updated)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn pick_folder() -> CommandResult<Option<Vec<String>>> {
|
||
Ok(rfd::FileDialog::new()
|
||
.pick_folder()
|
||
.map(|p| vec![p.to_string_lossy().to_string()]))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn repo_has_commit(repo_path: String, commit_hash: String) -> CommandResult<bool> {
|
||
Ok(run_git(&repo_path, &["branch", "--contains", &commit_hash])
|
||
.map(|raw| !raw.trim().is_empty())
|
||
.unwrap_or(false))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_daily_commit_stats(
|
||
state: tauri::State<'_, AppState>,
|
||
) -> CommandResult<HashMap<String, i64>> {
|
||
Ok(state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.daily_commit_stats
|
||
.clone())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_all_commit_hashes(folder_obj: FolderObj) -> CommandResult<Vec<String>> {
|
||
if folder_obj.needs_relocation || !Path::new(&folder_obj.path).exists() {
|
||
return Ok(Vec::new());
|
||
}
|
||
Ok(run_git(&folder_obj.path, &["log", "--all", "--format=%H"])
|
||
.unwrap_or_default()
|
||
.lines()
|
||
.map(|s| s.to_string())
|
||
.collect())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn trigger_rewrite_now(
|
||
app: AppHandle,
|
||
state: tauri::State<'_, AppState>,
|
||
folder_path: String,
|
||
) -> CommandResult<Value> {
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
let Some(folder) = store.folders.iter_mut().find(|f| f.path == folder_path) else {
|
||
return Ok(json!({ "success": false, "error": "folder not found" }));
|
||
};
|
||
if folder.needs_relocation {
|
||
return Ok(json!({ "success": false, "error": "needs relocation" }));
|
||
}
|
||
if folder.llm_candidates.is_empty() {
|
||
return Ok(json!({ "success": false, "error": "no candidates" }));
|
||
}
|
||
folder.rewrite_in_progress = false;
|
||
folder.rewrite_started_at = None;
|
||
folder.lines_changed = 0;
|
||
folder.first_candidate_birthday = Some(now_ms());
|
||
}
|
||
save_store(&state)?;
|
||
thread::spawn(move || {
|
||
let _ = run_llm_commit_rewrite(app, folder_path, true);
|
||
});
|
||
Ok(json!({ "success": true }))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct TreeContextInfo {
|
||
abs_path: String,
|
||
rel_path: String,
|
||
root: String,
|
||
#[serde(rename = "type")]
|
||
node_type: String,
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn show_folder_context_menu(
|
||
app: AppHandle,
|
||
window: WebviewWindow,
|
||
state: tauri::State<'_, AppState>,
|
||
folder_path: String,
|
||
) -> CommandResult<()> {
|
||
let open_id = menu_id("ctx_open", &folder_path);
|
||
let copy_id = menu_id("ctx_copy", &folder_path);
|
||
{
|
||
let mut actions = state.menu_actions.lock().map_err(|e| e.to_string())?;
|
||
actions.insert(
|
||
open_id.clone(),
|
||
MenuAction::ContextOpen(PathBuf::from(&folder_path)),
|
||
);
|
||
actions.insert(
|
||
copy_id.clone(),
|
||
MenuAction::ContextCopy(PathBuf::from(&folder_path)),
|
||
);
|
||
}
|
||
let open = MenuItem::with_id(&app, open_id, "Open Folder", true, None::<&str>)
|
||
.map_err(|e| e.to_string())?;
|
||
let copy = MenuItem::with_id(&app, copy_id, "Copy Folder Path", true, None::<&str>)
|
||
.map_err(|e| e.to_string())?;
|
||
let menu = Menu::with_items(&app, &[&open, ©]).map_err(|e| e.to_string())?;
|
||
window.popup_menu(&menu).map_err(|e| e.to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn show_tree_context_menu(
|
||
app: AppHandle,
|
||
window: WebviewWindow,
|
||
state: tauri::State<'_, AppState>,
|
||
info: TreeContextInfo,
|
||
) -> CommandResult<()> {
|
||
let open_id = menu_id("ctx_tree_open", &info.abs_path);
|
||
let copy_id = menu_id("ctx_tree_copy", &info.abs_path);
|
||
let ignore_id = menu_id("ctx_tree_ignore", &(info.root.clone() + &info.rel_path));
|
||
{
|
||
let mut actions = state.menu_actions.lock().map_err(|e| e.to_string())?;
|
||
actions.insert(
|
||
open_id.clone(),
|
||
MenuAction::ContextOpen(PathBuf::from(&info.abs_path)),
|
||
);
|
||
actions.insert(
|
||
copy_id.clone(),
|
||
MenuAction::ContextCopy(PathBuf::from(&info.abs_path)),
|
||
);
|
||
actions.insert(
|
||
ignore_id.clone(),
|
||
MenuAction::ContextGitignore {
|
||
root: PathBuf::from(&info.root),
|
||
rel: info.rel_path.clone(),
|
||
},
|
||
);
|
||
}
|
||
let open_label = if info.node_type == "dir" {
|
||
"Open Folder"
|
||
} else {
|
||
"Open File"
|
||
};
|
||
let copy_label = if info.node_type == "dir" {
|
||
"Copy Folder Path"
|
||
} else {
|
||
"Copy File Path"
|
||
};
|
||
let open = MenuItem::with_id(&app, open_id, open_label, true, None::<&str>)
|
||
.map_err(|e| e.to_string())?;
|
||
let copy = MenuItem::with_id(&app, copy_id, copy_label, true, None::<&str>)
|
||
.map_err(|e| e.to_string())?;
|
||
let add_ignore = MenuItem::with_id(&app, ignore_id, "Add to .gitignore", true, None::<&str>)
|
||
.map_err(|e| e.to_string())?;
|
||
let sep = PredefinedMenuItem::separator(&app).map_err(|e| e.to_string())?;
|
||
let menu =
|
||
Menu::with_items(&app, &[&open, ©, &sep, &add_ignore]).map_err(|e| e.to_string())?;
|
||
window.popup_menu(&menu).map_err(|e| e.to_string())
|
||
}
|
||
|
||
fn has_readme_internal(folder_path: &str) -> bool {
|
||
Path::new(folder_path).join("README.md").exists()
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn has_readme(folder_path: String) -> CommandResult<bool> {
|
||
Ok(has_readme_internal(&folder_path))
|
||
}
|
||
|
||
fn is_text_file(file_path: &Path) -> bool {
|
||
let ext = file_path
|
||
.extension()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("")
|
||
.to_lowercase();
|
||
if CODE_EXTS.contains(&ext.as_str()) {
|
||
return true;
|
||
}
|
||
let Ok(meta) = fs::metadata(file_path) else {
|
||
return false;
|
||
};
|
||
if meta.len() > 200 * 1024 {
|
||
return false;
|
||
}
|
||
let Ok(bytes) = fs::read(file_path) else {
|
||
return false;
|
||
};
|
||
!bytes.iter().take(400).any(|b| *b == 0)
|
||
}
|
||
|
||
fn gitignore_filter_ignores(base: &Path, path: &Path) -> bool {
|
||
let mut builder = GitignoreBuilder::new(base);
|
||
let gitignore = base.join(".gitignore");
|
||
if gitignore.exists() {
|
||
let _ = builder.add(gitignore);
|
||
}
|
||
let Ok(ig) = builder.build() else {
|
||
return false;
|
||
};
|
||
let rel = path.strip_prefix(base).unwrap_or(path);
|
||
ig.matched_path_or_any_parents(rel, path.is_dir())
|
||
.is_ignore()
|
||
}
|
||
|
||
fn relevance_score(file_path: &Path, rel_path: &Path, content: &str) -> i64 {
|
||
let base = file_path
|
||
.file_name()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("")
|
||
.to_lowercase();
|
||
let rel = rel_path.to_string_lossy().to_lowercase();
|
||
let mut score = 0;
|
||
if base.starts_with("main.")
|
||
|| base.starts_with("index.")
|
||
|| base.starts_with("app.")
|
||
|| base.starts_with("server.")
|
||
{
|
||
score += 20;
|
||
}
|
||
if [
|
||
"package.json",
|
||
"requirements.txt",
|
||
"pyproject.toml",
|
||
"makefile",
|
||
"cargo.toml",
|
||
]
|
||
.contains(&base.as_str())
|
||
{
|
||
score += 20;
|
||
}
|
||
if rel_path.parent() == Some(Path::new("")) || rel_path.parent() == Some(Path::new(".")) {
|
||
score += 10;
|
||
}
|
||
if rel.contains("test")
|
||
|| rel.contains("mock")
|
||
|| rel.contains("example")
|
||
|| rel.contains("spec")
|
||
|| rel.contains("demo")
|
||
{
|
||
score -= 30;
|
||
}
|
||
score += content.matches("export ").count() as i64 * 2;
|
||
score += content.matches("module.exports").count() as i64 * 2;
|
||
score += content.matches("function ").count() as i64;
|
||
score += content.matches("class ").count() as i64;
|
||
score += content.matches("\ndef ").count() as i64;
|
||
if content.lines().count() < 20 {
|
||
score -= 5;
|
||
}
|
||
if content.len() > 1500 {
|
||
score += 2;
|
||
}
|
||
score
|
||
}
|
||
|
||
fn get_relevant_files(folder_path: &str, max_size: u64) -> Vec<PathBuf> {
|
||
#[derive(Clone)]
|
||
struct Candidate {
|
||
path: PathBuf,
|
||
size: u64,
|
||
score: i64,
|
||
}
|
||
fn walk(base: &Path, current: &Path, out: &mut Vec<Candidate>) {
|
||
let Ok(entries) = fs::read_dir(current) else {
|
||
return;
|
||
};
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
if gitignore_filter_ignores(base, &path) {
|
||
continue;
|
||
}
|
||
let name = entry.file_name().to_string_lossy().to_string();
|
||
if path.is_dir() {
|
||
if name.starts_with('.')
|
||
|| ["node_modules", "dist", "dist-tauri", "target", "build"]
|
||
.contains(&name.as_str())
|
||
{
|
||
continue;
|
||
}
|
||
walk(base, &path, out);
|
||
} else if is_text_file(&path) {
|
||
let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||
let content = fs::read_to_string(&path).unwrap_or_default();
|
||
let rel = path.strip_prefix(base).unwrap_or(&path).to_path_buf();
|
||
let score = relevance_score(&path, &rel, &content);
|
||
out.push(Candidate { path, size, score });
|
||
}
|
||
}
|
||
}
|
||
let base = Path::new(folder_path);
|
||
let mut candidates = Vec::new();
|
||
walk(base, base, &mut candidates);
|
||
candidates.sort_by(|a, b| b.score.cmp(&a.score).then(a.size.cmp(&b.size)));
|
||
let mut selected = Vec::new();
|
||
let mut total = 0;
|
||
for c in candidates {
|
||
if total + c.size > max_size {
|
||
break;
|
||
}
|
||
total += c.size;
|
||
selected.push(c.path);
|
||
}
|
||
selected
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn generate_readme(
|
||
app: AppHandle,
|
||
state: tauri::State<'_, AppState>,
|
||
folder_path: String,
|
||
) -> CommandResult<String> {
|
||
let (author, license, model) = {
|
||
let store = state.store.lock().map_err(|e| e.to_string())?;
|
||
(
|
||
store
|
||
.author
|
||
.clone()
|
||
.unwrap_or_else(|| "Unknown".to_string()),
|
||
store.license.clone().unwrap_or_else(|| "MIT".to_string()),
|
||
store
|
||
.readme_model
|
||
.clone()
|
||
.unwrap_or_else(|| "qwen2.5-coder:32b".to_string()),
|
||
)
|
||
};
|
||
let repo_name = Path::new(&folder_path)
|
||
.file_name()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("Project")
|
||
.to_string();
|
||
let mut prompt = format!(
|
||
r#"You are a tool that generates README.md files in markdown format.
|
||
Do not review, suggest, or improve the code.
|
||
Your only job is to create a clear and concise README in markdown, suitable for immediate use on GitHub.
|
||
|
||
IMPORTANT: The LICENSE is {license}.
|
||
NEVER add Contact Details.
|
||
|
||
Now write a similar README.md for the following project:
|
||
|
||
Author: {author}
|
||
|
||
Source Code:
|
||
"#
|
||
);
|
||
for file in get_relevant_files(&folder_path, 100 * 1024) {
|
||
let rel = file.strip_prefix(&folder_path).unwrap_or(&file);
|
||
let content = fs::read_to_string(&file).unwrap_or_default();
|
||
prompt.push_str(&format!("\n---\nFile: {}\n{}\n", rel.display(), content));
|
||
}
|
||
prompt.push_str(&format!(
|
||
"\n---\nWrite ONLY the complete README.md in markdown format. Remember, the license is {license}!"
|
||
));
|
||
let result = stream_ollama(&prompt, &model, 0.4, &app)?;
|
||
let cleaned = result
|
||
.replace("```markdown", "")
|
||
.replace("```md", "")
|
||
.replace("```", "")
|
||
.trim()
|
||
.to_string();
|
||
let disclaimer = "> ⚠️ **This README.md has been automatically generated using AI and might contain hallucinations or inaccuracies. Please proceed with caution!**\n\n";
|
||
let final_text = format!("# {repo_name}\n\n**Author:** {author}\n\n{disclaimer}{cleaned}");
|
||
fs::write(Path::new(&folder_path).join("README.md"), &final_text).map_err(|e| e.to_string())?;
|
||
Ok(final_text)
|
||
}
|
||
|
||
fn get_commit_history_for_squash(repo_path: &str) -> CommandResult<Vec<SquashCommit>> {
|
||
let merge_commits =
|
||
run_git(repo_path, &["rev-list", "--min-parents=2", "HEAD"]).unwrap_or_default();
|
||
if !merge_commits.trim().is_empty() {
|
||
return Err("Smart squash currently only supports linear commit history.".to_string());
|
||
}
|
||
let raw = run_git(
|
||
repo_path,
|
||
&[
|
||
"log",
|
||
"--reverse",
|
||
"--format=%H%x1f%T%x1f%P%x1f%ct%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%B%x1e",
|
||
"HEAD",
|
||
],
|
||
)?;
|
||
let mut commits = Vec::new();
|
||
for entry in raw.split('\x1e').map(str::trim).filter(|s| !s.is_empty()) {
|
||
let parts: Vec<_> = entry.split('\x1f').collect();
|
||
if parts.len() < 11 {
|
||
continue;
|
||
}
|
||
commits.push(SquashCommit {
|
||
hash: parts[0].to_string(),
|
||
tree: parts[1].to_string(),
|
||
parents: parts[2].split_whitespace().map(|s| s.to_string()).collect(),
|
||
timestamp_ms: parts[3].parse::<i64>().unwrap_or(0) * 1000,
|
||
author_name: parts[4].to_string(),
|
||
author_email: parts[5].to_string(),
|
||
author_date: parts[6].to_string(),
|
||
committer_name: parts[7].to_string(),
|
||
committer_email: parts[8].to_string(),
|
||
committer_date: parts[9].to_string(),
|
||
message: parts[10..].join("\x1f").trim_end().to_string(),
|
||
});
|
||
}
|
||
Ok(commits)
|
||
}
|
||
|
||
fn detect_squash_chunks(commits: &[SquashCommit]) -> Vec<Vec<SquashCommit>> {
|
||
if commits.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let mut chunks: Vec<Vec<SquashCommit>> = vec![vec![commits[0].clone()]];
|
||
for pair in commits.windows(2) {
|
||
let previous = &pair[0];
|
||
let current = &pair[1];
|
||
if (current.timestamp_ms - previous.timestamp_ms).abs() <= SQUASH_CHUNK_WINDOW_MS {
|
||
chunks.last_mut().unwrap().push(current.clone());
|
||
} else {
|
||
chunks.push(vec![current.clone()]);
|
||
}
|
||
}
|
||
chunks
|
||
}
|
||
|
||
fn build_squash_fallback_message(commits: &[SquashCommit]) -> String {
|
||
let hashes = commits
|
||
.iter()
|
||
.map(|c| short_hash(&c.hash))
|
||
.collect::<Vec<_>>();
|
||
truncate_text(
|
||
format!("auto-git: [squash] {}", hashes.join(", ")),
|
||
MAX_SQUASH_COMMIT_MESSAGE_CHARS,
|
||
)
|
||
}
|
||
|
||
fn sanitize_squash_commit_message(raw: &str, commits: &[SquashCommit]) -> String {
|
||
let mut cleaned = normalize_single_line(raw)
|
||
.trim_matches(|c| c == '"' || c == '\'' || c == '`')
|
||
.to_string();
|
||
let lower = cleaned.to_lowercase();
|
||
if lower.starts_with("commit message:") {
|
||
cleaned = cleaned["commit message:".len()..].trim().to_string();
|
||
}
|
||
if cleaned.is_empty() {
|
||
cleaned = build_squash_fallback_message(commits);
|
||
}
|
||
truncate_text(cleaned, MAX_SQUASH_COMMIT_MESSAGE_CHARS)
|
||
}
|
||
|
||
fn generate_squash_commit_message_prompt(
|
||
repo_path: &str,
|
||
commits: &[SquashCommit],
|
||
) -> CommandResult<String> {
|
||
let oldest = commits.first().ok_or_else(|| "no commits".to_string())?;
|
||
let newest = commits.last().ok_or_else(|| "no commits".to_string())?;
|
||
let diff_base = oldest
|
||
.parents
|
||
.first()
|
||
.cloned()
|
||
.unwrap_or_else(|| EMPTY_TREE_HASH.to_string());
|
||
let name_status = run_git(
|
||
repo_path,
|
||
&["diff", "--name-status", &diff_base, &newest.hash],
|
||
)?;
|
||
let diff_stat = run_git(
|
||
repo_path,
|
||
&[
|
||
"diff",
|
||
"--stat",
|
||
"--compact-summary",
|
||
&diff_base,
|
||
&newest.hash,
|
||
],
|
||
)?;
|
||
let mut omitted = 0;
|
||
let commits_for_prompt: Vec<Value> = commits
|
||
.iter()
|
||
.map(|commit| {
|
||
let normalized = normalize_single_line(&commit.message);
|
||
if !normalized.is_empty() && normalized.len() <= MAX_SQUASH_PROMPT_MESSAGE_CHARS {
|
||
json!({ "hash": short_hash(&commit.hash), "message": normalized })
|
||
} else {
|
||
if !normalized.is_empty() {
|
||
omitted += 1;
|
||
}
|
||
json!({ "hash": short_hash(&commit.hash) })
|
||
}
|
||
})
|
||
.collect();
|
||
let omission_note = if omitted > 0 {
|
||
format!(
|
||
"Some original commit messages were omitted because they were too long ({omitted})."
|
||
)
|
||
} else {
|
||
String::new()
|
||
};
|
||
let prompt = format!(
|
||
r#"Analyze the following git commits that will be squashed into one commit.
|
||
Generate one concise commit message summarizing the combined actual change.
|
||
- Output ONLY the literal commit message text.
|
||
- Do NOT add markdown, quotes, bullet points, or explanations.
|
||
- Keep it under 140 characters.
|
||
|
||
COMMITS:
|
||
{}
|
||
|
||
{}
|
||
|
||
CHANGED FILES:
|
||
{}
|
||
|
||
DIFF STAT:
|
||
{}"#,
|
||
serde_json::to_string_pretty(&commits_for_prompt).map_err(|e| e.to_string())?,
|
||
omission_note,
|
||
{
|
||
let block = truncate_prompt_block(name_status, MAX_SQUASH_NAME_STATUS_CHARS);
|
||
if block.is_empty() {
|
||
"(none)".to_string()
|
||
} else {
|
||
block
|
||
}
|
||
},
|
||
{
|
||
let block = truncate_prompt_block(diff_stat, MAX_SQUASH_DIFFSTAT_CHARS);
|
||
if block.is_empty() {
|
||
"(none)".to_string()
|
||
} else {
|
||
block
|
||
}
|
||
},
|
||
);
|
||
if prompt.len() > MAX_SQUASH_PROMPT_CHARS {
|
||
return Err(format!(
|
||
"Squash prompt too large ({} chars) for {repo_path}",
|
||
prompt.len()
|
||
));
|
||
}
|
||
Ok(prompt)
|
||
}
|
||
|
||
fn generate_squash_commit_message(
|
||
repo_path: &str,
|
||
commits: &[SquashCommit],
|
||
model: &str,
|
||
app: &AppHandle,
|
||
) -> CommandResult<String> {
|
||
let prompt = generate_squash_commit_message_prompt(repo_path, commits)?;
|
||
let raw = stream_ollama(&prompt, model, 0.3, app)?;
|
||
Ok(sanitize_squash_commit_message(&raw, commits))
|
||
}
|
||
|
||
fn build_squash_plan(
|
||
repo_path: &str,
|
||
chunks: Vec<Vec<SquashCommit>>,
|
||
model: &str,
|
||
app: &AppHandle,
|
||
) -> Vec<SquashPlanEntry> {
|
||
chunks
|
||
.into_iter()
|
||
.map(|chunk| {
|
||
if chunk.len() == 1 {
|
||
SquashPlanEntry {
|
||
message: chunk[0].message.clone(),
|
||
commits: chunk,
|
||
}
|
||
} else {
|
||
let message = generate_squash_commit_message(repo_path, &chunk, model, app)
|
||
.unwrap_or_else(|_| build_squash_fallback_message(&chunk));
|
||
SquashPlanEntry {
|
||
commits: chunk,
|
||
message,
|
||
}
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn smart_squash_commits(app: AppHandle, folder_path: String) -> CommandResult<Value> {
|
||
if !Path::new(&folder_path).exists() {
|
||
return Err("Folder not found.".to_string());
|
||
}
|
||
let state = app.state::<AppState>();
|
||
let folder_obj = {
|
||
let store = state.store.lock().map_err(|e| e.to_string())?;
|
||
store
|
||
.folders
|
||
.iter()
|
||
.find(|f| f.path == folder_path)
|
||
.cloned()
|
||
.ok_or_else(|| "Folder is not tracked by auto-git.".to_string())?
|
||
};
|
||
if folder_obj.needs_relocation {
|
||
return Err("This folder needs to be relocated before squashing commits.".to_string());
|
||
}
|
||
if folder_obj.rewrite_in_progress {
|
||
return Err("Another rewrite is already running for this repository.".to_string());
|
||
}
|
||
if is_rebase_in_progress(&folder_path) {
|
||
return Err("A git rebase is already in progress for this repository.".to_string());
|
||
}
|
||
let current_branch = run_git(&folder_path, &["rev-parse", "--abbrev-ref", "HEAD"])?
|
||
.trim()
|
||
.to_string();
|
||
if current_branch.is_empty() || current_branch == "HEAD" {
|
||
return Err("Cannot squash commits while HEAD is detached.".to_string());
|
||
}
|
||
let commits = get_commit_history_for_squash(&folder_path)?;
|
||
if commits.len() < 2 {
|
||
return Ok(
|
||
json!({ "success": true, "squashedChunks": 0, "removedCommits": 0, "message": "Not enough commits to squash." }),
|
||
);
|
||
}
|
||
let chunks = detect_squash_chunks(&commits);
|
||
let squashable = chunks.iter().filter(|chunk| chunk.len() > 1).count();
|
||
if squashable == 0 {
|
||
return Ok(
|
||
json!({ "success": true, "squashedChunks": 0, "removedCommits": 0, "message": "No quick-succession commit chunks found." }),
|
||
);
|
||
}
|
||
let original_head = run_git(&folder_path, &["rev-parse", "HEAD"])?
|
||
.trim()
|
||
.to_string();
|
||
let status = git_status(&folder_path)?;
|
||
let mut stashed = false;
|
||
let mut stash_warning: Option<String> = None;
|
||
if has_status_changes(&status) {
|
||
run_git(&folder_path, &["stash", "push", "--include-untracked"])?;
|
||
stashed = true;
|
||
}
|
||
|
||
let model = {
|
||
let store = state.store.lock().map_err(|e| e.to_string())?;
|
||
store
|
||
.commit_model
|
||
.clone()
|
||
.unwrap_or_else(|| "qwen2.5-coder:7b".to_string())
|
||
};
|
||
let result = (|| -> CommandResult<String> {
|
||
let plan = build_squash_plan(&folder_path, chunks, &model, &app);
|
||
let mut new_head: Option<String> = None;
|
||
for entry in plan {
|
||
let last = entry.commits.last().unwrap();
|
||
let mut env = HashMap::new();
|
||
env.insert("GIT_AUTHOR_NAME".to_string(), last.author_name.clone());
|
||
env.insert("GIT_AUTHOR_EMAIL".to_string(), last.author_email.clone());
|
||
env.insert("GIT_AUTHOR_DATE".to_string(), last.author_date.clone());
|
||
env.insert(
|
||
"GIT_COMMITTER_NAME".to_string(),
|
||
if last.committer_name.is_empty() {
|
||
last.author_name.clone()
|
||
} else {
|
||
last.committer_name.clone()
|
||
},
|
||
);
|
||
env.insert(
|
||
"GIT_COMMITTER_EMAIL".to_string(),
|
||
if last.committer_email.is_empty() {
|
||
last.author_email.clone()
|
||
} else {
|
||
last.committer_email.clone()
|
||
},
|
||
);
|
||
env.insert(
|
||
"GIT_COMMITTER_DATE".to_string(),
|
||
last.committer_date.clone(),
|
||
);
|
||
let mut args = vec!["commit-tree".to_string(), last.tree.clone()];
|
||
if let Some(parent) = &new_head {
|
||
args.push("-p".to_string());
|
||
args.push(parent.clone());
|
||
}
|
||
let stdout = run_git_owned(
|
||
&folder_path,
|
||
&args,
|
||
Some(&env),
|
||
Some(&(entry.message + "\n")),
|
||
)?;
|
||
new_head = Some(stdout.trim().to_string());
|
||
}
|
||
let new_head =
|
||
new_head.ok_or_else(|| "git commit-tree did not return a commit hash.".to_string())?;
|
||
run_git(&folder_path, &["reset", "--hard", &new_head])?;
|
||
Ok(new_head)
|
||
})();
|
||
|
||
match result {
|
||
Ok(new_head) => {
|
||
if stashed {
|
||
if let Err(err) = run_git(&folder_path, &["stash", "pop"]) {
|
||
stash_warning = Some(err);
|
||
}
|
||
}
|
||
{
|
||
let mut store = state.store.lock().map_err(|e| e.to_string())?;
|
||
if let Some(folder) = store.folders.iter_mut().find(|f| f.path == folder_path) {
|
||
folder.rewrite_in_progress = false;
|
||
folder.rewrite_started_at = None;
|
||
folder.llm_candidates.clear();
|
||
folder.llm_buffer.clear();
|
||
folder.lines_changed = 0;
|
||
folder.first_candidate_birthday = None;
|
||
folder.last_head_hash = Some(new_head);
|
||
}
|
||
}
|
||
save_store(&state)?;
|
||
emit(&app, "repo-updated", folder_path.clone());
|
||
Ok(json!({
|
||
"success": true,
|
||
"squashedChunks": squashable,
|
||
"removedCommits": commits.len() - build_squash_plan_for_removed_count(&commits),
|
||
"warning": stash_warning
|
||
}))
|
||
}
|
||
Err(err) => {
|
||
let _ = run_git(&folder_path, &["reset", "--hard", &original_head]);
|
||
if stashed {
|
||
let _ = run_git(&folder_path, &["stash", "pop"]);
|
||
}
|
||
Err(err)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn build_squash_plan_for_removed_count(commits: &[SquashCommit]) -> usize {
|
||
detect_squash_chunks(commits)
|
||
.iter()
|
||
.map(|chunk| if chunk.is_empty() { 0 } else { 1 })
|
||
.sum::<usize>()
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn squash_commits(app: AppHandle, folder_path: String) -> CommandResult<Value> {
|
||
match smart_squash_commits(app, folder_path) {
|
||
Ok(value) => Ok(value),
|
||
Err(err) => Ok(json!({ "success": false, "error": err })),
|
||
}
|
||
}
|
||
|
||
fn generate_repo_description(
|
||
app: &AppHandle,
|
||
state: &AppState,
|
||
folder_path: &str,
|
||
) -> CommandResult<String> {
|
||
let repo_name = Path::new(folder_path)
|
||
.file_name()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("repository");
|
||
let top_files = get_relevant_files(folder_path, 100 * 1024)
|
||
.into_iter()
|
||
.take(5)
|
||
.filter_map(|f| {
|
||
f.strip_prefix(folder_path)
|
||
.ok()
|
||
.map(|p| p.to_string_lossy().to_string())
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let prompt = format!(
|
||
"You are an assistant that writes a very short (<255 chars) description for a new Git repository.\nDo NOT exceed 255 characters and do NOT add markdown or commentary.\n\nProject name: {repo_name}\n\nKey files:\n{}\n\nWrite one concise sentence or two under 255 chars.",
|
||
top_files.iter().map(|f| format!("- {f}")).collect::<Vec<_>>().join("\n")
|
||
);
|
||
let model = state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.readme_model
|
||
.clone()
|
||
.unwrap_or_else(|| "qwen2.5-coder:32b".to_string());
|
||
let raw = stream_ollama(&prompt, &model, 0.3, app)?;
|
||
Ok(truncate_text(
|
||
raw.replace("```markdown", "").replace("```", ""),
|
||
255,
|
||
))
|
||
}
|
||
|
||
fn slugify_repository_name(name: &str) -> String {
|
||
let mut slug = String::new();
|
||
let mut needs_separator = false;
|
||
for character in name.chars() {
|
||
if character.is_ascii_alphanumeric() {
|
||
if needs_separator && !slug.is_empty() {
|
||
slug.push('-');
|
||
}
|
||
slug.push(character.to_ascii_lowercase());
|
||
needs_separator = false;
|
||
} else {
|
||
needs_separator = !slug.is_empty();
|
||
}
|
||
}
|
||
if slug.is_empty() {
|
||
"repository".to_string()
|
||
} else {
|
||
slug
|
||
}
|
||
}
|
||
|
||
fn gitea_response_error(context: &str, response: reqwest::blocking::Response) -> String {
|
||
let status = response.status();
|
||
let body = response.text().unwrap_or_default();
|
||
let detail = serde_json::from_str::<Value>(&body)
|
||
.ok()
|
||
.and_then(|value| {
|
||
let message = value.get("message").and_then(Value::as_str);
|
||
let errors = value.get("errors").filter(|errors| !errors.is_null());
|
||
match (message, errors) {
|
||
(Some(message), Some(errors)) => Some(format!("{message}; {errors}")),
|
||
(Some(message), None) => Some(message.to_string()),
|
||
(None, Some(errors)) => Some(errors.to_string()),
|
||
(None, None) => None,
|
||
}
|
||
})
|
||
.or_else(|| (!body.trim().is_empty()).then(|| body.trim().to_string()))
|
||
.unwrap_or_else(|| "Gitea returned no error details".to_string());
|
||
format!(
|
||
"{context} failed ({status}): {}",
|
||
truncate_text(detail, 1000)
|
||
)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn push_to_gitea(
|
||
app: AppHandle,
|
||
state: tauri::State<'_, AppState>,
|
||
folder_path: String,
|
||
allow_dirty: bool,
|
||
) -> CommandResult<Value> {
|
||
let token = state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.gitea_token
|
||
.clone();
|
||
if token.is_empty() {
|
||
return Ok(
|
||
json!({ "success": false, "error": "No Gitea API token configured – open Settings and enter it first" }),
|
||
);
|
||
}
|
||
if has_status_changes(&git_status(&folder_path)?) && !allow_dirty {
|
||
return Ok(json!({
|
||
"success": false,
|
||
"code": "DIRTY_WORKTREE",
|
||
"error": "The repository contains uncommitted or untracked files. Confirm the warning before pushing."
|
||
}));
|
||
}
|
||
let result = (|| -> CommandResult<Value> {
|
||
let folder_name = Path::new(&folder_path)
|
||
.file_name()
|
||
.and_then(|s| s.to_str())
|
||
.ok_or_else(|| "invalid repository path".to_string())?;
|
||
let repo_name = slugify_repository_name(folder_name);
|
||
let base = "https://giers10.uber.space/api/v1";
|
||
let description = generate_repo_description(&app, &state, &folder_path)?;
|
||
let client = Client::new();
|
||
let user_resp = client
|
||
.get(format!("{base}/user"))
|
||
.header("Authorization", format!("token {token}"))
|
||
.send()
|
||
.map_err(|e| e.to_string())?;
|
||
if !user_resp.status().is_success() {
|
||
return Err(gitea_response_error("Gitea user request", user_resp));
|
||
}
|
||
let user: Value = user_resp.json().map_err(|e| e.to_string())?;
|
||
let username = user
|
||
.get("login")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| "Gitea user response missing login".to_string())?;
|
||
let check = client
|
||
.get(format!("{base}/repos/{username}/{repo_name}"))
|
||
.header("Authorization", format!("token {token}"))
|
||
.send()
|
||
.map_err(|e| e.to_string())?;
|
||
let repo_url = if check.status().as_u16() == 404 {
|
||
let created_response = client
|
||
.post(format!("{base}/user/repos"))
|
||
.header("Authorization", format!("token {token}"))
|
||
.json(&json!({
|
||
"name": repo_name,
|
||
"description": description,
|
||
"private": false,
|
||
"auto_init": false
|
||
}))
|
||
.send()
|
||
.map_err(|e| e.to_string())?;
|
||
if !created_response.status().is_success() {
|
||
return Err(gitea_response_error(
|
||
"Creating the Gitea repository",
|
||
created_response,
|
||
));
|
||
}
|
||
let created: Value = created_response.json().map_err(|e| e.to_string())?;
|
||
created
|
||
.get("clone_url")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| "Gitea create response missing clone_url".to_string())?
|
||
.to_string()
|
||
} else if check.status().is_success() {
|
||
let update_response = client
|
||
.patch(format!("{base}/repos/{username}/{repo_name}"))
|
||
.header("Authorization", format!("token {token}"))
|
||
.json(&json!({ "description": description }))
|
||
.send()
|
||
.map_err(|e| e.to_string())?;
|
||
if !update_response.status().is_success() {
|
||
return Err(gitea_response_error(
|
||
"Updating the Gitea repository description",
|
||
update_response,
|
||
));
|
||
}
|
||
let existing: Value = check.json().map_err(|e| e.to_string())?;
|
||
existing
|
||
.get("clone_url")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| "Gitea repo response missing clone_url".to_string())?
|
||
.to_string()
|
||
} else {
|
||
return Err(gitea_response_error("Checking the Gitea repository", check));
|
||
};
|
||
let _ = run_git(&folder_path, &["remote", "remove", "origin"]);
|
||
run_git(&folder_path, &["remote", "add", "origin", &repo_url])?;
|
||
let branch = run_git(&folder_path, &["rev-parse", "--abbrev-ref", "HEAD"])?
|
||
.trim()
|
||
.to_string();
|
||
run_git(
|
||
&folder_path,
|
||
&["push", "-u", "origin", &branch, "--force", "--tags"],
|
||
)?;
|
||
Ok(json!({ "success": true, "repoUrl": repo_url }))
|
||
})();
|
||
Ok(result.unwrap_or_else(|err| json!({ "success": false, "error": err })))
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn get_gitea_token(state: tauri::State<'_, AppState>) -> CommandResult<String> {
|
||
Ok(state
|
||
.store
|
||
.lock()
|
||
.map_err(|e| e.to_string())?
|
||
.gitea_token
|
||
.clone())
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn set_gitea_token(state: tauri::State<'_, AppState>, token: String) -> CommandResult<()> {
|
||
state.store.lock().map_err(|e| e.to_string())?.gitea_token = token;
|
||
save_store(&state)
|
||
}
|
||
|
||
fn main() {
|
||
let path = store_path();
|
||
let store = load_store(&path);
|
||
let state = AppState {
|
||
store: Mutex::new(store),
|
||
store_path: path,
|
||
watchers: Mutex::new(HashMap::new()),
|
||
pending: Mutex::new(HashMap::new()),
|
||
active: Mutex::new(HashSet::new()),
|
||
menu_actions: Mutex::new(HashMap::new()),
|
||
quitting: AtomicBool::new(false),
|
||
tray: Mutex::new(None),
|
||
};
|
||
|
||
tauri::Builder::default()
|
||
.manage(state)
|
||
.setup(|app| {
|
||
build_app_menu(app)?;
|
||
app.on_menu_event(|app_handle, event| {
|
||
let id = event.id().0.as_str().to_string();
|
||
let action = if id == "settings" {
|
||
Some(MenuAction::Settings)
|
||
} else if id == "quit" {
|
||
Some(MenuAction::Quit)
|
||
} else {
|
||
app_handle
|
||
.state::<AppState>()
|
||
.menu_actions
|
||
.lock()
|
||
.ok()
|
||
.and_then(|actions| actions.get(&id).cloned())
|
||
};
|
||
if let Some(action) = action {
|
||
handle_menu_action(app_handle, action);
|
||
}
|
||
});
|
||
|
||
let app_handle = app.handle().clone();
|
||
if let Err(err) = update_tray_menu(&app_handle) {
|
||
eprintln!("[AutoGit] failed to build tray menu: {err}");
|
||
}
|
||
let folders = app
|
||
.state::<AppState>()
|
||
.store
|
||
.lock()
|
||
.map(|store| store.folders.clone())
|
||
.unwrap_or_default();
|
||
for folder in folders {
|
||
if folder.monitoring {
|
||
let _ = start_monitoring_watcher(app_handle.clone(), folder.path, false);
|
||
}
|
||
}
|
||
let monitor_app = app_handle.clone();
|
||
thread::spawn(move || loop {
|
||
let _ = update_folders_listener(&monitor_app);
|
||
thread::sleep(Duration::from_secs(3));
|
||
});
|
||
Ok(())
|
||
})
|
||
.on_window_event(|window, event| {
|
||
if window.label() == "main" {
|
||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||
let state = window.state::<AppState>();
|
||
let close_to_tray = state
|
||
.store
|
||
.lock()
|
||
.map(|store| store.close_to_tray)
|
||
.unwrap_or(false);
|
||
if close_to_tray && !state.quitting.load(Ordering::SeqCst) {
|
||
api.prevent_close();
|
||
let _ = window.hide();
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.invoke_handler(tauri::generate_handler![
|
||
get_selected,
|
||
set_selected,
|
||
get_folders,
|
||
add_folder,
|
||
add_folder_by_path,
|
||
remove_folder,
|
||
get_commit_count,
|
||
has_diffs,
|
||
remove_git_folder,
|
||
get_commits,
|
||
diff_commit,
|
||
revert_commit,
|
||
checkout_commit,
|
||
snapshot_commit,
|
||
get_theme,
|
||
set_theme,
|
||
get_skip_git_prompt,
|
||
set_skip_git_prompt,
|
||
get_folder_tree,
|
||
commit_current_folder,
|
||
set_monitoring,
|
||
ollama_list,
|
||
ollama_pull,
|
||
get_commit_model,
|
||
set_commit_model,
|
||
get_readme_model,
|
||
set_readme_model,
|
||
get_intelligent_commit_threshold,
|
||
set_intelligent_commit_threshold,
|
||
get_minutes_commit_threshold,
|
||
set_minutes_commit_threshold,
|
||
get_autostart,
|
||
set_autostart,
|
||
get_close_to_tray,
|
||
set_close_to_tray,
|
||
close_settings,
|
||
is_git_repo,
|
||
init_repo,
|
||
relocate_folder,
|
||
pick_folder,
|
||
repo_has_commit,
|
||
get_daily_commit_stats,
|
||
get_all_commit_hashes,
|
||
trigger_rewrite_now,
|
||
show_folder_context_menu,
|
||
show_tree_context_menu,
|
||
has_readme,
|
||
generate_readme,
|
||
squash_commits,
|
||
push_to_gitea,
|
||
get_gitea_token,
|
||
set_gitea_token
|
||
])
|
||
.run(tauri::generate_context!())
|
||
.expect("error while running tauri application");
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn repository_names_are_slugified_for_gitea() {
|
||
assert_eq!(
|
||
slugify_repository_name("EP-133 Sample Tool Offline"),
|
||
"ep-133-sample-tool-offline"
|
||
);
|
||
assert_eq!(slugify_repository_name(" Fragile___App "), "fragile-app");
|
||
assert_eq!(slugify_repository_name("☃"), "repository");
|
||
}
|
||
|
||
#[test]
|
||
fn existing_project_ignore_rules_are_written_before_staging() {
|
||
let temp = TempDir::new().unwrap();
|
||
fs::write(temp.path().join(".DS_Store"), "metadata").unwrap();
|
||
fs::create_dir(temp.path().join("Release.app")).unwrap();
|
||
fs::write(temp.path().join("Release.app").join("binary"), "binary").unwrap();
|
||
|
||
update_gitignore_from_existing_project(temp.path().to_str().unwrap()).unwrap();
|
||
|
||
let gitignore = fs::read_to_string(temp.path().join(".gitignore")).unwrap();
|
||
assert!(gitignore.lines().any(|line| line == ".DS_Store"));
|
||
assert!(gitignore.lines().any(|line| line == "*.app"));
|
||
}
|
||
|
||
#[test]
|
||
fn reword_hashes_are_processed_newest_first() {
|
||
let all_commits = vec![
|
||
"ccccccc333333333333333333333333333333333".to_string(),
|
||
"bbbbbbb222222222222222222222222222222222".to_string(),
|
||
"aaaaaaa111111111111111111111111111111111".to_string(),
|
||
];
|
||
let hashes = vec![
|
||
"aaaaaaa".to_string(),
|
||
"ccccccc".to_string(),
|
||
"bbbbbbb".to_string(),
|
||
];
|
||
|
||
assert_eq!(
|
||
resolve_reword_hashes_newest_first(&all_commits, &hashes),
|
||
all_commits
|
||
);
|
||
}
|
||
}
|