Compare commits
12 Commits
16abf010ab
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3206363a88 | |||
| 5d655e4207 | |||
| 692ea1b1ed | |||
| e621ac04f7 | |||
| 7241974d8b | |||
| 10385bb1c2 | |||
| 5f7649867b | |||
| bd2b87fd51 | |||
| 4f27c895fd | |||
| e1f8ee95da | |||
| c85f51b205 | |||
| dbdacfc0bd |
152
gemini.md
152
gemini.md
@@ -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**.
|
||||
|
||||
---
|
||||
|
||||
# Core Features
|
||||
|
||||
## Background Operation
|
||||
@@ -25,7 +23,6 @@ The tray icon menu must contain:
|
||||
* **Select Repository Folder**
|
||||
* **Exit Application**
|
||||
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
### .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:
|
||||
...
|
||||
|
||||
* File created
|
||||
* File modified
|
||||
@@ -55,15 +56,13 @@ delete: old_part.nc
|
||||
rename: part_v1.nc -> part_v2.nc
|
||||
```
|
||||
|
||||
### Git Operations
|
||||
Commits must be executed automatically using Git.
|
||||
|
||||
If the directory is not a Git repository, the application should:
|
||||
|
||||
* notify the user
|
||||
* not start monitoring
|
||||
|
||||
---
|
||||
|
||||
# Windows Notifications
|
||||
|
||||
The application should generate **Windows toast notifications** for:
|
||||
@@ -80,8 +79,6 @@ Git Monitor
|
||||
modify: program.nc committed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Logging
|
||||
|
||||
The application must write logs to a file.
|
||||
@@ -104,8 +101,6 @@ Logged events:
|
||||
* git commit executed
|
||||
* errors and exceptions
|
||||
|
||||
---
|
||||
|
||||
# System Tray UI
|
||||
|
||||
The application must provide a **tray icon**.
|
||||
@@ -134,8 +129,6 @@ Once selected:
|
||||
|
||||
Stops monitoring and terminates the application.
|
||||
|
||||
---
|
||||
|
||||
# Application Architecture
|
||||
|
||||
The application must be **modular** and structured using classes.
|
||||
@@ -154,8 +147,6 @@ config.py
|
||||
logger.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Modules
|
||||
|
||||
## main.py
|
||||
@@ -174,8 +165,6 @@ Main class:
|
||||
Application
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# tray_app.py
|
||||
|
||||
Handles the **system tray icon and menu**.
|
||||
@@ -207,8 +196,6 @@ stop_monitoring()
|
||||
exit_app()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# file_watcher.py
|
||||
|
||||
Responsible for filesystem monitoring.
|
||||
@@ -240,8 +227,6 @@ on_deleted
|
||||
on_moved
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# git_manager.py
|
||||
|
||||
Handles all Git operations.
|
||||
@@ -277,7 +262,6 @@ Commit format:
|
||||
f"{action}: {file}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 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:
|
||||
|
||||
* single executable
|
||||
* no console window
|
||||
|
||||
Example build command:
|
||||
The project is structured as a proper Python package to ensure compatibility with PyInstaller and robust path handling.
|
||||
|
||||
```
|
||||
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
|
||||
* git errors
|
||||
* filesystem watcher errors
|
||||
* missing permissions
|
||||
|
||||
Errors must:
|
||||
|
||||
* be logged
|
||||
* generate a Windows notification
|
||||
### 2. PyInstaller Compatibility
|
||||
* **Package Imports**: All internal imports use absolute paths (e.g., `from git_monitor.logger import ...`).
|
||||
* **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.
|
||||
* **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`.
|
||||
|
||||
---
|
||||
|
||||
# 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
|
||||
* debounce filesystem events if necessary
|
||||
* run with minimal CPU usage
|
||||
|
||||
---
|
||||
|
||||
# Recommended Python Libraries
|
||||
|
||||
```
|
||||
watchdog
|
||||
GitPython
|
||||
pystray
|
||||
pillow
|
||||
win10toast
|
||||
```powershell
|
||||
pyinstaller.exe --onefile --noconsole --name git-monitor --hidden-import="plyer.platforms.win.notification" run.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Example Workflow
|
||||
|
||||
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
|
||||
### Build Parameters:
|
||||
* `--onefile`: Packages everything into a single `.exe`.
|
||||
* `--noconsole`: Hides the command prompt window during execution.
|
||||
* `--hidden-import`: Manually includes the dynamic notification module for Windows.
|
||||
|
||||
---
|
||||
|
||||
# Future Improvements
|
||||
|
||||
Possible future features:
|
||||
|
||||
* 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)
|
||||
# Runtime Files
|
||||
* **Log File**: `git_monitor.log` (created in the same folder as the `.exe`).
|
||||
* **Config File**: `config.json` (created in the same folder as the `.exe`).
|
||||
|
||||
0
git_monitor/__init__.py
Normal file
0
git_monitor/__init__.py
Normal file
@@ -1,9 +1,11 @@
|
||||
import time
|
||||
import os
|
||||
import threading
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from logger import logger
|
||||
from git_manager import git_manager
|
||||
from git_monitor.logger import logger
|
||||
from git_monitor.git_manager import git_manager
|
||||
from git_monitor.notifier import notifier
|
||||
|
||||
class RepositoryWatcher:
|
||||
def __init__(self, path):
|
||||
@@ -43,22 +45,35 @@ class GitEventHandler(FileSystemEventHandler):
|
||||
self.handle_event(event, action, custom_file_name=file_name)
|
||||
|
||||
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_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":
|
||||
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
|
||||
logger.info(f"File event: {action} on {display_name}")
|
||||
|
||||
logger.info(f"Change detected: {action} on {display_name}")
|
||||
|
||||
repo_root = git_manager.repo_path
|
||||
if repo_root:
|
||||
if custom_file_name:
|
||||
# For renames, we use the custom format provided
|
||||
git_manager.commit_change(action, custom_file_name)
|
||||
else:
|
||||
rel_path = os.path.relpath(file_path, repo_root)
|
||||
rel_path = os.path.relpath(file_path, repo_root) if not custom_file_name else custom_file_name
|
||||
|
||||
# Interactive notification instead of immediate commit
|
||||
def save_now():
|
||||
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
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
from git import Repo, exc
|
||||
from logger import logger
|
||||
from notifier import notifier
|
||||
from git_monitor.logger import logger
|
||||
from git_monitor.notifier import notifier
|
||||
|
||||
class GitManager:
|
||||
def __init__(self, repo_path=None):
|
||||
@@ -34,6 +34,18 @@ class GitManager:
|
||||
except (exc.InvalidGitRepositoryError, exc.NoSuchPathError):
|
||||
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):
|
||||
if not self.repo:
|
||||
logger.error("No repository loaded for commit.")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from logger import logger
|
||||
from tray_app import TrayApp
|
||||
from git_monitor.logger import logger
|
||||
from git_monitor.tray_app import TrayApp
|
||||
|
||||
class Application:
|
||||
def __init__(self):
|
||||
|
||||
@@ -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:
|
||||
from plyer import notification
|
||||
PLYER_AVAILABLE = True
|
||||
except ImportError:
|
||||
logger.warning("plyer not found. Notifications will be printed to stdout.")
|
||||
PLYER_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from win10toast import ToastNotifier
|
||||
WIN10TOAST_AVAILABLE = True
|
||||
legacy_toaster = ToastNotifier()
|
||||
except ImportError:
|
||||
WIN10TOAST_AVAILABLE = False
|
||||
|
||||
class Notifier:
|
||||
def notify(self, title, message):
|
||||
"""Simple notification (no buttons)."""
|
||||
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:
|
||||
try:
|
||||
notification.notify(
|
||||
title=title,
|
||||
message=message,
|
||||
app_name="Git Monitor",
|
||||
# timeout=10
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error showing notification: {e}")
|
||||
else:
|
||||
print(f"[{title}] {message}")
|
||||
notification.notify(title=title, message=message, app_name="Git Monitor")
|
||||
return
|
||||
except: pass
|
||||
|
||||
if WIN10TOAST_AVAILABLE:
|
||||
try:
|
||||
legacy_toaster.show_toast(title, message, duration=5, threaded=True)
|
||||
return
|
||||
except: pass
|
||||
|
||||
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()
|
||||
|
||||
@@ -4,3 +4,4 @@ pystray
|
||||
Pillow
|
||||
plyer
|
||||
win10toast
|
||||
windows-toasts
|
||||
|
||||
@@ -5,11 +5,11 @@ from tkinter import filedialog
|
||||
from PIL import Image, ImageDraw
|
||||
import pystray
|
||||
from pystray import MenuItem as item
|
||||
from logger import logger
|
||||
from config import config
|
||||
from git_manager import git_manager
|
||||
from file_watcher import RepositoryWatcher
|
||||
from notifier import notifier
|
||||
from git_monitor.logger import logger
|
||||
from git_monitor.config import config
|
||||
from git_monitor.git_manager import git_manager
|
||||
from git_monitor.file_watcher import RepositoryWatcher
|
||||
from git_monitor.notifier import notifier
|
||||
|
||||
class TrayApp:
|
||||
def __init__(self):
|
||||
@@ -42,14 +42,33 @@ class TrayApp:
|
||||
self.icon = pystray.Icon("GitMonitor", image, "Git Monitor", menu)
|
||||
|
||||
def select_repository(self, icon=None, item=None):
|
||||
# Open folder dialog in a separate thread or use the hidden root
|
||||
# tkinter dialogs need to run in the main thread or with care
|
||||
repo_path = filedialog.askdirectory(title="Select Git Repository Folder")
|
||||
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)
|
||||
logger.info("Opening folder selection dialog...")
|
||||
# Use a temporary function to run in a way that doesn't block
|
||||
def ask_folder():
|
||||
try:
|
||||
# Ensure the root window is focused and on top
|
||||
self.root.deiconify()
|
||||
self.root.attributes("-topmost", True)
|
||||
|
||||
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):
|
||||
if self.watcher:
|
||||
@@ -75,7 +94,10 @@ class TrayApp:
|
||||
|
||||
def run(self):
|
||||
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__":
|
||||
app = TrayApp()
|
||||
|
||||
10
run.py
10
run.py
@@ -1,12 +1,4 @@
|
||||
import sys
|
||||
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
|
||||
from git_monitor.main import Application
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = Application()
|
||||
|
||||
Reference in New Issue
Block a user