Skip to content
121 changes: 121 additions & 0 deletions agent-support/sublime-text/git_ai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""
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.
"""

from __future__ import annotations

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:
_timers.pop(repo_root, None)
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,
)
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}")


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()


def plugin_unloaded():
with _lock:
for t in _timers.values():
t.cancel()
_timers.clear()
_pending.clear()
3 changes: 3 additions & 0 deletions src/mdm/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod gemini;
mod github_copilot;
mod jetbrains;
mod opencode;
mod sublime_text;
mod vscode;
mod windsurf;

Expand All @@ -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;

Expand All @@ -40,6 +42,7 @@ pub fn get_all_installers() -> Vec<Box<dyn HookInstaller>> {
Box::new(DroidInstaller),
Box::new(FirebenderInstaller),
Box::new(JetBrainsInstaller),
Box::new(SublimeTextInstaller),
Box::new(WindsurfInstaller),
]
}
229 changes: 229 additions & 0 deletions src/mdm/agents/sublime_text.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
use crate::error::GitAiError;
use crate::mdm::hook_installer::{
HookCheckResult, HookInstaller, HookInstallerParams, InstallResult, UninstallResult,
};
use crate::mdm::utils::binary_exists;
#[cfg(not(windows))]
use crate::mdm::utils::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<PathBuf> {
#[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<PathBuf> {
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())
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 is_plugin_installed check fails on Windows due to path escaping mismatch

On Windows, install_extras writes the binary path with doubled backslashes for Python string escaping (replace('\', "\\\\") at sublime_text.rs:156), so the installed file contains e.g. C:\\Users\\.... However, is_plugin_installed at line 73 checks content.contains(&binary_path.display().to_string()) which produces a string with single backslashes (C:\Users\...). Since the single-backslash string is not a substring of the double-backslash content, the check always returns false on Windows after a successful install. This causes check_hooks to report hooks_installed: false and install_extras to redundantly overwrite the plugin on every run.

Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

}
}

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<HookCheckResult, GitAiError> {
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(&params.binary_path);
Ok(HookCheckResult {
tool_installed,
hooks_installed,
hooks_up_to_date: hooks_installed,
})
}

fn install_hooks(
&self,
_params: &HookInstallerParams,
_dry_run: bool,
) -> Result<Option<String>, GitAiError> {
Ok(None)
}

fn uninstall_hooks(
&self,
_params: &HookInstallerParams,
_dry_run: bool,
) -> Result<Option<String>, GitAiError> {
Ok(None)
}

fn install_extras(
&self,
params: &HookInstallerParams,
dry_run: bool,
) -> Result<Vec<InstallResult>, 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(&params.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('\\', "\\\\")
.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<Vec<UninstallResult>, 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(),
}])
}
Comment on lines +177 to +204
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ”΄ uninstall_extras performs destructive remove_dir_all even during dry run

The _dry_run parameter is prefixed with underscore and completely ignored. At line 189, fs::remove_dir_all(dir)? unconditionally deletes the entire git-ai plugin directory even when dry_run is true. Every other installer in the codebase guards destructive file operations with if !dry_run β€” see amp.rs:128, opencode.rs:157.

Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

}

#[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(&params, false).unwrap().is_none());
}
}
Loading