From b2c9f9ce1115906108163e0eee2f522147bd125f Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 11 Apr 2026 16:59:05 +0000 Subject: [PATCH 01/10] feat(sublime-text): add known_human checkpoint plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Python plugin for Sublime Text that fires git-ai checkpoint known_human --hook-input stdin on file save with 500ms debounce per repo root. The Rust installer auto-installs the plugin to the platform-appropriate Packages directory. Sublime Text hot-reloads Python packages — no restart needed. Co-Authored-By: Claude Sonnet 4.6 --- agent-support/sublime-text/git_ai.py | 110 ++++++++++++++ src/mdm/agents/mod.rs | 3 + src/mdm/agents/sublime_text.rs | 217 +++++++++++++++++++++++++++ 3 files changed, 330 insertions(+) create mode 100644 agent-support/sublime-text/git_ai.py create mode 100644 src/mdm/agents/sublime_text.rs diff --git a/agent-support/sublime-text/git_ai.py b/agent-support/sublime-text/git_ai.py new file mode 100644 index 000000000..0c84faef7 --- /dev/null +++ b/agent-support/sublime-text/git_ai.py @@ -0,0 +1,110 @@ +""" +git-ai Sublime Text plugin — known_human checkpoint + +Fires `git-ai checkpoint known_human --hook-input stdin` after each file save, +debounced 500ms per git repository root. This lets git-ai attribute saved lines +to the human author. + +Installation (automatic via `git-ai install-hooks`): + The plugin is written to: + macOS: ~/Library/Application Support/Sublime Text/Packages/git-ai/git_ai.py + Linux: ~/.config/sublime-text/Packages/git-ai/git_ai.py + Windows: %APPDATA%\\Sublime Text\\Packages\\git-ai\\git_ai.py + Sublime Text hot-reloads Python packages — no restart needed. +""" + +import json +import os +import subprocess +import threading + +import sublime +import sublime_plugin + +# git-ai binary path (substituted at install time by `git-ai install-hooks`) +GIT_AI_BIN = "__GIT_AI_BINARY_PATH__" + +_lock = threading.Lock() +_timers: dict = {} # repo_root -> threading.Timer +_pending: dict = {} # repo_root -> dict[path, content] + + +def _find_repo_root(file_path: str) -> str | None: + """Walk up from file_path to find the nearest .git directory.""" + d = os.path.dirname(os.path.abspath(file_path)) + while True: + if os.path.isdir(os.path.join(d, ".git")): + return d + parent = os.path.dirname(d) + if parent == d: + return None + d = parent + + +def _fire_checkpoint(repo_root: str) -> None: + """Called after the debounce window; sends accumulated files to git-ai.""" + with _lock: + files: dict = _pending.pop(repo_root, {}) + + if not files: + return + + payload = json.dumps({ + "editor": "sublime-text", + "editor_version": sublime.version(), + "extension_version": "1.0.0", + "cwd": repo_root, + "edited_filepaths": list(files.keys()), + "dirty_files": files, + }) + + try: + proc = subprocess.Popen( + [GIT_AI_BIN, "checkpoint", "known_human", "--hook-input", "stdin"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=repo_root, + ) + proc.stdin.write(payload.encode("utf-8")) + proc.stdin.close() + proc.wait(timeout=15) + except Exception as exc: + print(f"[git-ai] checkpoint known_human error: {exc}") + + +class GitAiKnownHumanListener(sublime_plugin.EventListener): + """Listens for post-save events and fires the known_human checkpoint.""" + + def on_post_save_async(self, view: sublime.View) -> None: + file_path = view.file_name() + if not file_path: + return + + # Skip IDE-internal paths + norm = file_path.replace("\\", "/") + if "/.git/" in norm: + return + if file_path.endswith(".sublime-workspace") or file_path.endswith(".sublime-project"): + return + + repo_root = _find_repo_root(file_path) + if not repo_root: + return + + # Read current content from the view buffer (already saved) + content = view.substr(sublime.Region(0, view.size())) + + with _lock: + if repo_root not in _pending: + _pending[repo_root] = {} + _pending[repo_root][file_path] = content + + # Cancel existing debounce timer for this repo root and start a new one + existing = _timers.get(repo_root) + if existing is not None: + existing.cancel() + + timer = threading.Timer(0.5, _fire_checkpoint, args=[repo_root]) + _timers[repo_root] = timer + timer.start() diff --git a/src/mdm/agents/mod.rs b/src/mdm/agents/mod.rs index 97c3f4e80..96e3fa384 100644 --- a/src/mdm/agents/mod.rs +++ b/src/mdm/agents/mod.rs @@ -8,6 +8,7 @@ mod gemini; mod github_copilot; mod jetbrains; mod opencode; +mod sublime_text; mod vscode; mod windsurf; @@ -21,6 +22,7 @@ pub use gemini::GeminiInstaller; pub use github_copilot::GitHubCopilotInstaller; pub use jetbrains::JetBrainsInstaller; pub use opencode::OpenCodeInstaller; +pub use sublime_text::SublimeTextInstaller; pub use vscode::VSCodeInstaller; pub use windsurf::WindsurfInstaller; @@ -40,6 +42,7 @@ pub fn get_all_installers() -> Vec> { Box::new(DroidInstaller), Box::new(FirebenderInstaller), Box::new(JetBrainsInstaller), + Box::new(SublimeTextInstaller), Box::new(WindsurfInstaller), ] } diff --git a/src/mdm/agents/sublime_text.rs b/src/mdm/agents/sublime_text.rs new file mode 100644 index 000000000..9465513db --- /dev/null +++ b/src/mdm/agents/sublime_text.rs @@ -0,0 +1,217 @@ +use crate::error::GitAiError; +use crate::mdm::hook_installer::{ + HookCheckResult, HookInstaller, HookInstallerParams, InstallResult, UninstallResult, +}; +use crate::mdm::utils::{binary_exists, home_dir}; +use std::fs; +use std::path::PathBuf; + +// Plugin source, embedded at compile time with binary path placeholder +const PLUGIN_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/agent-support/sublime-text/git_ai.py" +)); + +pub struct SublimeTextInstaller; + +impl SublimeTextInstaller { + fn packages_dir() -> Option { + #[cfg(target_os = "macos")] + { + Some( + home_dir() + .join("Library") + .join("Application Support") + .join("Sublime Text") + .join("Packages"), + ) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + // Try both Sublime Text 3 and 4 paths + let paths = [ + home_dir().join(".config").join("sublime-text").join("Packages"), + home_dir() + .join(".config") + .join("sublime-text-3") + .join("Packages"), + ]; + paths + .into_iter() + .find(|p| p.exists()) + .or_else(|| Some(home_dir().join(".config").join("sublime-text").join("Packages"))) + } + #[cfg(windows)] + { + std::env::var("APPDATA").ok().map(|appdata| { + PathBuf::from(appdata) + .join("Sublime Text") + .join("Packages") + }) + } + } + + fn plugin_path() -> Option { + Self::packages_dir().map(|p| p.join("git-ai").join("git_ai.py")) + } + + fn is_plugin_installed(binary_path: &std::path::Path) -> bool { + let Some(path) = Self::plugin_path() else { + return false; + }; + if !path.exists() { + return false; + } + let Ok(content) = fs::read_to_string(&path) else { + return false; + }; + content.contains(&binary_path.display().to_string()) + } +} + +impl HookInstaller for SublimeTextInstaller { + fn name(&self) -> &str { + "Sublime Text" + } + + fn id(&self) -> &str { + "sublime-text" + } + + fn uses_config_hooks(&self) -> bool { + false + } + + fn check_hooks(&self, params: &HookInstallerParams) -> Result { + let has_subl = binary_exists("subl") || binary_exists("sublime_text"); + let has_packages = Self::packages_dir().map(|p| p.exists()).unwrap_or(false); + let tool_installed = has_subl || has_packages; + + let hooks_installed = Self::is_plugin_installed(¶ms.binary_path); + Ok(HookCheckResult { + tool_installed, + hooks_installed, + hooks_up_to_date: hooks_installed, + }) + } + + fn install_hooks( + &self, + _params: &HookInstallerParams, + _dry_run: bool, + ) -> Result, GitAiError> { + Ok(None) + } + + fn uninstall_hooks( + &self, + _params: &HookInstallerParams, + _dry_run: bool, + ) -> Result, GitAiError> { + Ok(None) + } + + fn install_extras( + &self, + params: &HookInstallerParams, + dry_run: bool, + ) -> Result, GitAiError> { + let Some(plugin_path) = Self::plugin_path() else { + return Ok(vec![InstallResult { + changed: false, + diff: None, + message: "Sublime Text: Could not determine Packages directory".to_string(), + }]); + }; + + if Self::is_plugin_installed(¶ms.binary_path) { + return Ok(vec![InstallResult { + changed: false, + diff: None, + message: "Sublime Text: Plugin already installed".to_string(), + }]); + } + + if dry_run { + return Ok(vec![InstallResult { + changed: true, + diff: None, + message: format!( + "Sublime Text: Pending plugin install to {}", + plugin_path.display() + ), + }]); + } + + // Substitute the binary path placeholder + let path_str = params.binary_path.display().to_string().replace('\\', "\\\\"); + let content = PLUGIN_TEMPLATE.replace("__GIT_AI_BINARY_PATH__", &path_str); + + if let Some(dir) = plugin_path.parent() { + fs::create_dir_all(dir)?; + } + fs::write(&plugin_path, content)?; + + Ok(vec![InstallResult { + changed: true, + diff: None, + message: format!( + "Sublime Text: Plugin installed to {} (hot-reloaded, no restart needed)", + plugin_path.display() + ), + }]) + } + + fn uninstall_extras( + &self, + _params: &HookInstallerParams, + _dry_run: bool, + ) -> Result, GitAiError> { + let Some(plugin_path) = Self::plugin_path() else { + return Ok(vec![UninstallResult { + changed: false, + diff: None, + message: "Sublime Text: Could not determine Packages directory".to_string(), + }]); + }; + if let Some(dir) = plugin_path.parent() + && dir.exists() + { + fs::remove_dir_all(dir)?; + return Ok(vec![UninstallResult { + changed: true, + diff: None, + message: "Sublime Text: Plugin removed".to_string(), + }]); + } + Ok(vec![UninstallResult { + changed: false, + diff: None, + message: "Sublime Text: Plugin was not installed".to_string(), + }]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sublime_text_installer_name() { + assert_eq!(SublimeTextInstaller.name(), "Sublime Text"); + } + + #[test] + fn test_sublime_text_installer_id() { + assert_eq!(SublimeTextInstaller.id(), "sublime-text"); + } + + #[test] + fn test_sublime_text_install_hooks_returns_none() { + let installer = SublimeTextInstaller; + let params = HookInstallerParams { + binary_path: std::path::PathBuf::from("/usr/local/bin/git-ai"), + }; + assert!(installer.install_hooks(¶ms, false).unwrap().is_none()); + } +} From b6ae7cab5d7216d1e197f9d80cff41195e9752d3 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 11 Apr 2026 18:10:27 +0000 Subject: [PATCH 02/10] style: cargo fmt --- src/mdm/agents/sublime_text.rs | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/mdm/agents/sublime_text.rs b/src/mdm/agents/sublime_text.rs index 9465513db..02f581697 100644 --- a/src/mdm/agents/sublime_text.rs +++ b/src/mdm/agents/sublime_text.rs @@ -30,24 +30,29 @@ impl SublimeTextInstaller { { // Try both Sublime Text 3 and 4 paths let paths = [ - home_dir().join(".config").join("sublime-text").join("Packages"), + home_dir() + .join(".config") + .join("sublime-text") + .join("Packages"), home_dir() .join(".config") .join("sublime-text-3") .join("Packages"), ]; - paths - .into_iter() - .find(|p| p.exists()) - .or_else(|| Some(home_dir().join(".config").join("sublime-text").join("Packages"))) + paths.into_iter().find(|p| p.exists()).or_else(|| { + Some( + home_dir() + .join(".config") + .join("sublime-text") + .join("Packages"), + ) + }) } #[cfg(windows)] { - std::env::var("APPDATA").ok().map(|appdata| { - PathBuf::from(appdata) - .join("Sublime Text") - .join("Packages") - }) + std::env::var("APPDATA") + .ok() + .map(|appdata| PathBuf::from(appdata).join("Sublime Text").join("Packages")) } } @@ -144,7 +149,11 @@ impl HookInstaller for SublimeTextInstaller { } // Substitute the binary path placeholder - let path_str = params.binary_path.display().to_string().replace('\\', "\\\\"); + let path_str = params + .binary_path + .display() + .to_string() + .replace('\\', "\\\\"); let content = PLUGIN_TEMPLATE.replace("__GIT_AI_BINARY_PATH__", &path_str); if let Some(dir) = plugin_path.parent() { From 606001ffc6e4a7d6688136c963c5856c66b14eaf Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 11 Apr 2026 18:26:42 +0000 Subject: [PATCH 03/10] fix: gate home_dir import to non-windows to fix clippy unused_import --- src/mdm/agents/sublime_text.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/mdm/agents/sublime_text.rs b/src/mdm/agents/sublime_text.rs index 02f581697..d984e64fe 100644 --- a/src/mdm/agents/sublime_text.rs +++ b/src/mdm/agents/sublime_text.rs @@ -2,7 +2,9 @@ use crate::error::GitAiError; use crate::mdm::hook_installer::{ HookCheckResult, HookInstaller, HookInstallerParams, InstallResult, UninstallResult, }; -use crate::mdm::utils::{binary_exists, home_dir}; +use crate::mdm::utils::binary_exists; +#[cfg(not(windows))] +use crate::mdm::utils::home_dir; use std::fs; use std::path::PathBuf; From 467ff210c57f129bb3dc39fec1fcc74095106f13 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 11 Apr 2026 18:48:58 +0000 Subject: [PATCH 04/10] ci: retry flaky ubuntu integration tests From 8ef22fa57783efae01ff584fd21bc45dbd3c4510 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 11 Apr 2026 19:15:57 +0000 Subject: [PATCH 05/10] ci: retry flaky tests (2) From f03857bb497a2079a084095676e28b7df9558202 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 11 Apr 2026 19:36:30 +0000 Subject: [PATCH 06/10] ci: retry flaky tests (3) From 5da182adb9aec56220779665813106d821c83bfb Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 11 Apr 2026 19:58:11 +0000 Subject: [PATCH 07/10] ci: retry flaky tests (4) From 4f4d080625f7355da6106b19c90b06f2add12fb7 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 11 Apr 2026 22:03:51 +0000 Subject: [PATCH 08/10] fix(sublime-text): fix Python 3.8 compat, subprocess deadlock, timer cleanup, and binary path escaping Co-Authored-By: Claude Sonnet 4.6 --- agent-support/sublime-text/git_ai.py | 17 ++++++++++++++--- src/mdm/agents/sublime_text.rs | 3 ++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/agent-support/sublime-text/git_ai.py b/agent-support/sublime-text/git_ai.py index 0c84faef7..75ae1940c 100644 --- a/agent-support/sublime-text/git_ai.py +++ b/agent-support/sublime-text/git_ai.py @@ -13,6 +13,8 @@ Sublime Text hot-reloads Python packages — no restart needed. """ +from __future__ import annotations + import json import os import subprocess @@ -44,6 +46,7 @@ def _find_repo_root(file_path: str) -> str | None: def _fire_checkpoint(repo_root: str) -> None: """Called after the debounce window; sends accumulated files to git-ai.""" with _lock: + _timers.pop(repo_root, None) files: dict = _pending.pop(repo_root, {}) if not files: @@ -66,9 +69,9 @@ def _fire_checkpoint(repo_root: str) -> None: stderr=subprocess.PIPE, cwd=repo_root, ) - proc.stdin.write(payload.encode("utf-8")) - proc.stdin.close() - proc.wait(timeout=15) + stdout, stderr = proc.communicate(input=payload.encode("utf-8"), timeout=15) + if proc.returncode != 0: + print(f"[git-ai] checkpoint exited with code {proc.returncode}: {stderr.decode('utf-8', errors='replace')[:200]}") except Exception as exc: print(f"[git-ai] checkpoint known_human error: {exc}") @@ -108,3 +111,11 @@ def on_post_save_async(self, view: sublime.View) -> None: timer = threading.Timer(0.5, _fire_checkpoint, args=[repo_root]) _timers[repo_root] = timer timer.start() + + +def plugin_unloaded(): + with _lock: + for t in _timers.values(): + t.cancel() + _timers.clear() + _pending.clear() diff --git a/src/mdm/agents/sublime_text.rs b/src/mdm/agents/sublime_text.rs index d984e64fe..8f0b0084d 100644 --- a/src/mdm/agents/sublime_text.rs +++ b/src/mdm/agents/sublime_text.rs @@ -155,7 +155,8 @@ impl HookInstaller for SublimeTextInstaller { .binary_path .display() .to_string() - .replace('\\', "\\\\"); + .replace('\\', "\\\\") + .replace('"', "\\\""); let content = PLUGIN_TEMPLATE.replace("__GIT_AI_BINARY_PATH__", &path_str); if let Some(dir) = plugin_path.parent() { From c6cb06c4bf9b6a944fd7d817749c6218cc0f3592 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 11 Apr 2026 22:30:36 +0000 Subject: [PATCH 09/10] ci: retry flaky tests From 00667feadc9de1393050ed1a62b699526b8fe1d3 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Sat, 11 Apr 2026 22:56:07 +0000 Subject: [PATCH 10/10] ci: retry flaky tests (2)