Compare commits

...

12 Commits

9 changed files with 202 additions and 159 deletions

152
gemini.md
View File

@@ -10,8 +10,6 @@ The application runs **silently in the background** with a **system tray icon**
The final application must be packaged using **PyInstaller as a single executable file** with **no console window**. The final application must be packaged using **PyInstaller as a single executable file** with **no console window**.
---
# Core Features # Core Features
## Background Operation ## Background Operation
@@ -25,7 +23,6 @@ The tray icon menu must contain:
* **Select Repository Folder** * **Select Repository Folder**
* **Exit Application** * **Exit Application**
---
# Git Monitoring Behavior # Git Monitoring Behavior
@@ -33,7 +30,11 @@ The application monitors a selected folder that contains a **Git repository**.
Every filesystem change triggers an automatic commit. Every filesystem change triggers an automatic commit.
### .gitignore Support
The application respects `.gitignore` rules of the repository. Files matched by ignore rules will not trigger any actions or commits.
The following actions must be detected: The following actions must be detected:
...
* File created * File created
* File modified * File modified
@@ -55,15 +56,13 @@ delete: old_part.nc
rename: part_v1.nc -> part_v2.nc rename: part_v1.nc -> part_v2.nc
``` ```
### Git Operations
Commits must be executed automatically using Git. Commits must be executed automatically using Git.
If the directory is not a Git repository, the application should: If the directory is not a Git repository, the application should:
* notify the user * notify the user
* not start monitoring * not start monitoring
---
# Windows Notifications # Windows Notifications
The application should generate **Windows toast notifications** for: The application should generate **Windows toast notifications** for:
@@ -80,8 +79,6 @@ Git Monitor
modify: program.nc committed modify: program.nc committed
``` ```
---
# Logging # Logging
The application must write logs to a file. The application must write logs to a file.
@@ -104,8 +101,6 @@ Logged events:
* git commit executed * git commit executed
* errors and exceptions * errors and exceptions
---
# System Tray UI # System Tray UI
The application must provide a **tray icon**. The application must provide a **tray icon**.
@@ -134,8 +129,6 @@ Once selected:
Stops monitoring and terminates the application. Stops monitoring and terminates the application.
---
# Application Architecture # Application Architecture
The application must be **modular** and structured using classes. The application must be **modular** and structured using classes.
@@ -154,8 +147,6 @@ config.py
logger.py logger.py
``` ```
---
# Modules # Modules
## main.py ## main.py
@@ -174,8 +165,6 @@ Main class:
Application Application
``` ```
---
# tray_app.py # tray_app.py
Handles the **system tray icon and menu**. Handles the **system tray icon and menu**.
@@ -207,8 +196,6 @@ stop_monitoring()
exit_app() exit_app()
``` ```
---
# file_watcher.py # file_watcher.py
Responsible for filesystem monitoring. Responsible for filesystem monitoring.
@@ -240,8 +227,6 @@ on_deleted
on_moved on_moved
``` ```
---
# git_manager.py # git_manager.py
Handles all Git operations. Handles all Git operations.
@@ -277,7 +262,6 @@ Commit format:
f"{action}: {file}" f"{action}: {file}"
``` ```
---
# notifier.py # notifier.py
@@ -346,112 +330,62 @@ Example:
} }
``` ```
--- # Project Status: Completed
# Packaging The application is fully implemented and tested for Windows 11 background operation.
The application must be packaged using **PyInstaller**. # Current Architecture (Finalized)
Requirements: The project is structured as a proper Python package to ensure compatibility with PyInstaller and robust path handling.
* single executable
* no console window
Example build command:
``` ```
pyinstaller --onefile --noconsole main.py auto-git/
├── run.py # Main entry point for Python/PyInstaller
├── git_monitor/ # Main package
│ ├── __init__.py # Package marker
│ ├── main.py # Application lifecycle
│ ├── tray_app.py # System tray (pystray) + UI (tkinter)
│ ├── git_manager.py # Git operations (GitPython)
│ ├── file_watcher.py # Filesystem monitoring (watchdog)
│ ├── notifier.py # Windows notifications (plyer + win10toast fallback)
│ ├── config.py # Persistent configuration (config.json)
│ ├── logger.py # Application logging (git_monitor.log)
│ └── requirements.txt # Dependency list
└── .gitignore # Project ignore rules
``` ```
--- ---
# Error Handling # Implemented Solutions & Fixes
The application must gracefully handle: ### 1. UI Responsive Fix (Windows 11)
To prevent the application from freezing during folder selection:
* **Threading**: `pystray` runs in a separate background thread.
* **Main Loop**: `tkinter.mainloop()` runs in the main thread to handle system dialogs properly.
* **Non-blocking Dialogs**: Folder selection is scheduled via `root.after(0, ...)` to ensure it doesn't block the tray icon.
* invalid repository path ### 2. PyInstaller Compatibility
* git errors * **Package Imports**: All internal imports use absolute paths (e.g., `from git_monitor.logger import ...`).
* filesystem watcher errors * **Path Management**: `logger.py` and `config.py` use `sys.frozen` detection to ensure data files are always located relative to the `.exe` file, not temporary directories.
* missing permissions * **Notification Robustness**: Added a fallback mechanism in `notifier.py`. If `plyer` fails to find a platform implementation (common in isolated environments), it automatically switches to `win10toast`.
Errors must:
* be logged
* generate a Windows notification
--- ---
# Performance Considerations # Build Instructions
The application should: To generate the standalone executable, use the following command from the project root:
* avoid duplicate commits for rapid file changes ```powershell
* debounce filesystem events if necessary pyinstaller.exe --onefile --noconsole --name git-monitor --hidden-import="plyer.platforms.win.notification" run.py
* run with minimal CPU usage
---
# Recommended Python Libraries
```
watchdog
GitPython
pystray
pillow
win10toast
``` ```
--- ### Build Parameters:
* `--onefile`: Packages everything into a single `.exe`.
# Example Workflow * `--noconsole`: Hides the command prompt window during execution.
* `--hidden-import`: Manually includes the dynamic notification module for Windows.
1. Application starts
2. Tray icon appears
3. User selects repository
4. Monitoring begins
5. User edits a file
6. File watcher detects modification
7. GitManager stages file
8. Commit created automatically
9. Notification appears
10. Event logged
--- ---
# Future Improvements # Runtime Files
* **Log File**: `git_monitor.log` (created in the same folder as the `.exe`).
Possible future features: * **Config File**: `config.json` (created in the same folder as the `.exe`).
* commit batching
* ignore patterns (.gitignore support)
* commit history viewer
* push to remote repository
* repository auto-detection
* configuration GUI
* multiple repository support
---
# Coding Style
Requirements:
* Python 3.11+
* object-oriented design
* clear class responsibilities
* structured logging
* minimal global state
---
# Summary
The goal is to create a **lightweight background Windows application** that automatically commits changes in a Git repository without requiring the user to interact with Git.
The system should be:
* stable
* silent
* automatic
* easy to deploy (single EXE)
* minimal UI (tray only)

0
git_monitor/__init__.py Normal file
View File

View File

@@ -1,9 +1,11 @@
import time import time
import os import os
import threading
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
from logger import logger from git_monitor.logger import logger
from git_manager import git_manager from git_monitor.git_manager import git_manager
from git_monitor.notifier import notifier
class RepositoryWatcher: class RepositoryWatcher:
def __init__(self, path): def __init__(self, path):
@@ -43,22 +45,35 @@ class GitEventHandler(FileSystemEventHandler):
self.handle_event(event, action, custom_file_name=file_name) self.handle_event(event, action, custom_file_name=file_name)
def handle_event(self, event, action, custom_file_name=None): def handle_event(self, event, action, custom_file_name=None):
# Ignore .git directory and the log file
file_path = event.src_path if action != "rename" else event.dest_path file_path = event.src_path if action != "rename" else event.dest_path
file_name = os.path.basename(file_path) file_name = os.path.basename(file_path)
# Check if any part of the path is .git
if ".git" in file_path.split(os.sep) or file_name == "git_monitor.log": if ".git" in file_path.split(os.sep) or file_name == "git_monitor.log":
return return
if git_manager.is_ignored(file_path):
logger.info(f"Ignored: {file_path} (matches .gitignore)")
return
display_name = custom_file_name if custom_file_name else file_name display_name = custom_file_name if custom_file_name else file_name
logger.info(f"File event: {action} on {display_name}") logger.info(f"Change detected: {action} on {display_name}")
repo_root = git_manager.repo_path repo_root = git_manager.repo_path
if repo_root: if repo_root:
if custom_file_name: rel_path = os.path.relpath(file_path, repo_root) if not custom_file_name else custom_file_name
# For renames, we use the custom format provided
git_manager.commit_change(action, custom_file_name) # Interactive notification instead of immediate commit
else: def save_now():
rel_path = os.path.relpath(file_path, repo_root)
git_manager.commit_change(action, rel_path) git_manager.commit_change(action, rel_path)
def ask_later():
logger.info(f"User deferred change: {rel_path} for 5 minutes.")
# Reschedule the same event after 5 minutes (300 seconds)
threading.Timer(300, self.handle_event, [event, action, custom_file_name]).start()
notifier.notify_interactive(
"Zmiana wykryta!",
f"Wykryto zmianę: {action}: {display_name}. Co chcesz zrobić?",
on_save=save_now,
on_later=ask_later
)

View File

@@ -1,7 +1,7 @@
import os import os
from git import Repo, exc from git import Repo, exc
from logger import logger from git_monitor.logger import logger
from notifier import notifier from git_monitor.notifier import notifier
class GitManager: class GitManager:
def __init__(self, repo_path=None): def __init__(self, repo_path=None):
@@ -34,6 +34,18 @@ class GitManager:
except (exc.InvalidGitRepositoryError, exc.NoSuchPathError): except (exc.InvalidGitRepositoryError, exc.NoSuchPathError):
return False return False
def is_ignored(self, file_path):
"""Check if a file is ignored by .gitignore rules."""
if not self.repo:
return False
try:
# git check-ignore returns the path if it is ignored
ignored_files = self.repo.ignored(file_path)
return len(ignored_files) > 0
except Exception as e:
logger.error(f"Error checking ignore status for {file_path}: {e}")
return False
def commit_change(self, action, file_name): def commit_change(self, action, file_name):
if not self.repo: if not self.repo:
logger.error("No repository loaded for commit.") logger.error("No repository loaded for commit.")

View File

@@ -1,5 +1,5 @@
from logger import logger from git_monitor.logger import logger
from tray_app import TrayApp from git_monitor.tray_app import TrayApp
class Application: class Application:
def __init__(self): def __init__(self):

View File

@@ -1,26 +1,93 @@
from logger import logger import threading
from git_monitor.logger import logger
# Modern Windows 10/11 toasts with buttons
try:
from windows_toasts import WindowsToaster, ToastText2, ToastActivatedEventArgs, ToastButton
WINDOWS_TOASTS_AVAILABLE = True
toaster = WindowsToaster('Git Monitor')
except ImportError:
logger.warning("windows-toasts not found. Interactive buttons will not be available.")
WINDOWS_TOASTS_AVAILABLE = False
# Fallbacks
try: try:
from plyer import notification from plyer import notification
PLYER_AVAILABLE = True PLYER_AVAILABLE = True
except ImportError: except ImportError:
logger.warning("plyer not found. Notifications will be printed to stdout.")
PLYER_AVAILABLE = False PLYER_AVAILABLE = False
try:
from win10toast import ToastNotifier
WIN10TOAST_AVAILABLE = True
legacy_toaster = ToastNotifier()
except ImportError:
WIN10TOAST_AVAILABLE = False
class Notifier: class Notifier:
def notify(self, title, message): def notify(self, title, message):
"""Simple notification (no buttons)."""
logger.info(f"Notification: {title} - {message}") logger.info(f"Notification: {title} - {message}")
if WINDOWS_TOASTS_AVAILABLE:
try:
toast = ToastText2()
toast.headline = title
toast.body = message
toaster.show_toast(toast)
return
except Exception as e:
logger.error(f"windows-toasts simple notification failed: {e}")
# Fallback to older libraries
if PLYER_AVAILABLE: if PLYER_AVAILABLE:
try: try:
notification.notify( notification.notify(title=title, message=message, app_name="Git Monitor")
title=title, return
message=message, except: pass
app_name="Git Monitor",
# timeout=10 if WIN10TOAST_AVAILABLE:
) try:
except Exception as e: legacy_toaster.show_toast(title, message, duration=5, threaded=True)
logger.error(f"Error showing notification: {e}") return
else: except: pass
print(f"[{title}] {message}")
def notify_interactive(self, title, message, on_save, on_later):
"""Interactive notification with 'Save now' and 'Ask in 5 min' buttons."""
logger.info(f"Interactive Notification: {title} - {message}")
if not WINDOWS_TOASTS_AVAILABLE:
logger.warning("Interactive notifications not available, performing default action (Save).")
on_save()
return
try:
toast = ToastText2()
toast.headline = title
toast.body = message
# Action buttons
toast.AddAction(ToastButton("Zapisz teraz", "save"))
toast.AddAction(ToastButton("Zapytaj mnie za 5 min", "later"))
# Handler for button clicks
def on_activated(args: ToastActivatedEventArgs):
if args.arguments == "save":
logger.info("User clicked 'Zapisz teraz'")
on_save()
elif args.arguments == "later":
logger.info("User clicked 'Zapytaj mnie za 5 min'")
on_later()
else:
# Default click on toast body
on_save()
toast.on_activated = on_activated
toaster.show_toast(toast)
except Exception as e:
logger.error(f"Interactive notification error: {e}")
# Fallback to immediate save if notification fails
on_save()
notifier = Notifier() notifier = Notifier()

View File

@@ -4,3 +4,4 @@ pystray
Pillow Pillow
plyer plyer
win10toast win10toast
windows-toasts

View File

@@ -5,11 +5,11 @@ from tkinter import filedialog
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
import pystray import pystray
from pystray import MenuItem as item from pystray import MenuItem as item
from logger import logger from git_monitor.logger import logger
from config import config from git_monitor.config import config
from git_manager import git_manager from git_monitor.git_manager import git_manager
from file_watcher import RepositoryWatcher from git_monitor.file_watcher import RepositoryWatcher
from notifier import notifier from git_monitor.notifier import notifier
class TrayApp: class TrayApp:
def __init__(self): def __init__(self):
@@ -42,14 +42,33 @@ class TrayApp:
self.icon = pystray.Icon("GitMonitor", image, "Git Monitor", menu) self.icon = pystray.Icon("GitMonitor", image, "Git Monitor", menu)
def select_repository(self, icon=None, item=None): def select_repository(self, icon=None, item=None):
# Open folder dialog in a separate thread or use the hidden root logger.info("Opening folder selection dialog...")
# tkinter dialogs need to run in the main thread or with care # Use a temporary function to run in a way that doesn't block
repo_path = filedialog.askdirectory(title="Select Git Repository Folder") def ask_folder():
if repo_path: try:
logger.info(f"User selected repository: {repo_path}") # Ensure the root window is focused and on top
if git_manager.load_repository(repo_path): self.root.deiconify()
config.repository_path = repo_path self.root.attributes("-topmost", True)
self.start_monitoring(repo_path)
repo_path = filedialog.askdirectory(
parent=self.root,
title="Select Git Repository Folder"
)
self.root.withdraw()
if repo_path:
logger.info(f"User selected repository: {repo_path}")
if git_manager.load_repository(repo_path):
config.repository_path = repo_path
self.start_monitoring(repo_path)
else:
logger.info("Folder selection cancelled by user.")
except Exception as e:
logger.error(f"Error in folder selection: {e}")
# Schedule the dialog to run
self.root.after(0, ask_folder)
def start_monitoring(self, path): def start_monitoring(self, path):
if self.watcher: if self.watcher:
@@ -75,7 +94,10 @@ class TrayApp:
def run(self): def run(self):
self.create_icon() self.create_icon()
self.icon.run() # Run pystray in a separate thread
threading.Thread(target=self.icon.run, daemon=True).start()
# Keep the main thread for tkinter mainloop to handle dialogs
self.root.mainloop()
if __name__ == "__main__": if __name__ == "__main__":
app = TrayApp() app = TrayApp()

10
run.py
View File

@@ -1,12 +1,4 @@
import sys from git_monitor.main import Application
import os
# Ensure the git_monitor directory is in the search path
current_dir = os.path.dirname(os.path.abspath(__file__))
git_monitor_dir = os.path.join(current_dir, "git_monitor")
sys.path.insert(0, git_monitor_dir)
from main import Application
if __name__ == "__main__": if __name__ == "__main__":
app = Application() app = Application()