-
Notifications
You must be signed in to change notification settings - Fork 138
feat(sublime-text): add known_human checkpoint plugin #1049
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b2c9f9c
b6ae7ca
606001f
467ff21
8ef22fa
f03857b
5da182a
4f4d080
c6cb06c
00667fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() |
| 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()) | ||
| } | ||
| } | ||
|
|
||
| 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(¶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<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(¶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('\\', "\\\\") | ||
| .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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π΄ The 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(¶ms, false).unwrap().is_none()); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π‘
is_plugin_installedcheck fails on Windows due to path escaping mismatchOn Windows,
install_extraswrites the binary path with doubled backslashes for Python string escaping (replace('\', "\\\\")atsublime_text.rs:156), so the installed file contains e.g.C:\\Users\\.... However,is_plugin_installedat line 73 checkscontent.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 returnsfalseon Windows after a successful install. This causescheck_hooksto reporthooks_installed: falseandinstall_extrasto redundantly overwrite the plugin on every run.Was this helpful? React with π or π to provide feedback.