Compare commits

...

23 Commits

Author SHA1 Message Date
3206363a88 feat: use interactive notifications in file watcher with deferral support 2026-03-06 21:03:34 +01:00
5d655e4207 feat: add interactive notifications with buttons using windows-toasts 2026-03-06 21:03:04 +01:00
692ea1b1ed chore: add windows-toasts to requirements 2026-03-06 21:02:45 +01:00
e621ac04f7 docs: revert some changes 2026-03-06 20:45:30 +01:00
7241974d8b docs: add .gitignore support description to gemini.md 2026-03-06 20:41:30 +01:00
10385bb1c2 feat: implement .gitignore support in file watcher 2026-03-06 20:40:48 +01:00
5f7649867b docs: cleaning gemini.md 2026-03-06 20:36:54 +01:00
bd2b87fd51 docs: revert some changes in gemini.md 2026-03-06 20:13:27 +01:00
4f27c895fd docs: finalize gemini.md with implementation details and build instructions 2026-03-06 20:09:04 +01:00
e1f8ee95da fix: add win10toast fallback for notifications if plyer fails in PyInstaller 2026-03-06 20:05:24 +01:00
c85f51b205 fix: convert git_monitor to proper package and fix absolute imports for PyInstaller compatibility 2026-03-06 20:01:29 +01:00
dbdacfc0bd fix: resolve UI freeze by separating pystray and tkinter event loops 2026-03-06 19:45:56 +01:00
16abf010ab fix: improve path handling for PyInstaller compatibility 2026-03-06 19:31:50 +01:00
1b6c7dcc01 refactor: move files to git_monitor/ directory and add run.py 2026-03-06 19:30:27 +01:00
d2556bf656 chore: update gitignore 2026-03-06 19:28:41 +01:00
ce65a3ecd8 chore: add requirements.txt 2026-03-06 19:27:37 +01:00
e77054ba76 feat: add main.py entry point 2026-03-06 19:26:54 +01:00
d812e2db76 feat: add tray_app.py with pystray and tkinter 2026-03-06 19:26:32 +01:00
cb432568ca feat: add file_watcher.py with watchdog 2026-03-06 19:25:02 +01:00
77e498c4f1 feat: add git_manager.py for Git operations 2026-03-06 19:22:50 +01:00
76528decda feat: add notifier.py for windows notifications 2026-03-06 19:20:32 +01:00
7b137a7d3b feat: add config.py for persistent settings 2026-03-06 19:19:52 +01:00
8753a25cc6 chore: initial structure and logger.py 2026-03-06 19:19:09 +01:00
14 changed files with 502 additions and 398 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
git_monitor.log
config.json
.vscode
venv-win
moje-repo

154
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**.
---
# 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
@@ -286,7 +270,7 @@ Responsible for Windows notifications.
Recommended library:
```
win10toast
win10toast or plyer
```
Main class:
@@ -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
View File

45
git_monitor/config.py Normal file
View File

@@ -0,0 +1,45 @@
import json
import os
import sys
def get_app_dir():
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
else:
# If in git_monitor package, go up to the project root
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG_FILE = os.path.join(get_app_dir(), "config.json")
class Config:
def __init__(self):
self.data = {
"repository_path": ""
}
self.load()
def load(self):
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r") as f:
self.data = json.load(f)
except Exception as e:
print(f"Error loading config: {e}")
def save(self):
try:
with open(CONFIG_FILE, "w") as f:
json.dump(self.data, f, indent=4)
except Exception as e:
print(f"Error saving config: {e}")
@property
def repository_path(self):
return self.data.get("repository_path", "")
@repository_path.setter
def repository_path(self, value):
self.data["repository_path"] = value
self.save()
config = Config()

View File

@@ -0,0 +1,79 @@
import time
import os
import threading
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
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):
self.path = path
self.observer = Observer()
self.event_handler = GitEventHandler()
def start(self):
self.observer.schedule(self.event_handler, self.path, recursive=True)
self.observer.start()
logger.info(f"Started monitoring: {self.path}")
def stop(self):
self.observer.stop()
self.observer.join()
logger.info(f"Stopped monitoring: {self.path}")
class GitEventHandler(FileSystemEventHandler):
def on_modified(self, event):
if not event.is_directory:
self.handle_event(event, "modify")
def on_created(self, event):
if not event.is_directory:
self.handle_event(event, "create")
def on_deleted(self, event):
if not event.is_directory:
self.handle_event(event, "delete")
def on_moved(self, event):
if not event.is_directory:
src_name = os.path.basename(event.src_path)
dest_name = os.path.basename(event.dest_path)
action = "rename"
file_name = f"{src_name} -> {dest_name}"
self.handle_event(event, action, custom_file_name=file_name)
def handle_event(self, event, action, custom_file_name=None):
file_path = event.src_path if action != "rename" else event.dest_path
file_name = os.path.basename(file_path)
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"Change detected: {action} on {display_name}")
repo_root = git_manager.repo_path
if 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
)

View File

@@ -0,0 +1,72 @@
import os
from git import Repo, exc
from git_monitor.logger import logger
from git_monitor.notifier import notifier
class GitManager:
def __init__(self, repo_path=None):
self.repo_path = repo_path
self.repo = None
if repo_path:
self.load_repository(repo_path)
def load_repository(self, path):
if self.is_git_repository(path):
try:
self.repo = Repo(path)
self.repo_path = path
logger.info(f"Loaded repository: {path}")
return True
except Exception as e:
logger.error(f"Failed to load repository {path}: {e}")
notifier.notify("Git Error", f"Failed to load repository: {e}")
else:
logger.warning(f"{path} is not a valid Git repository.")
notifier.notify("Git Error", f"{path} is not a valid Git repository.")
return False
def is_git_repository(self, path):
if not path or not os.path.isdir(path):
return False
try:
Repo(path).git_dir
return True
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.")
return False
commit_msg = f"{action}: {file_name}"
try:
# Stage all changes (simple approach for the CNC operator use-case)
self.repo.git.add(A=True)
# Check if there are changes to commit
if self.repo.is_dirty(untracked_files=True):
self.repo.index.commit(commit_msg)
logger.info(f"Committed: {commit_msg}")
notifier.notify("Git Monitor", commit_msg + " committed")
return True
else:
logger.info(f"No changes to commit for: {file_name}")
return False
except Exception as e:
logger.error(f"Git commit failed: {e}")
notifier.notify("Git Error", f"Commit failed: {e}")
return False
git_manager = GitManager()

32
git_monitor/logger.py Normal file
View File

@@ -0,0 +1,32 @@
import logging
import os
import sys
def setup_logger():
# Find application directory
if getattr(sys, 'frozen', False):
app_dir = os.path.dirname(sys.executable)
else:
# If in a package, go up one level to the root
app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
log_file = os.path.join(app_dir, "git_monitor.log")
logger = logging.getLogger("GitMonitor")
logger.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
# File handler
file_handler = logging.FileHandler(log_file)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Also log to stdout for development (though it will be --noconsole eventually)
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
return logger
logger = setup_logger()

19
git_monitor/main.py Normal file
View File

@@ -0,0 +1,19 @@
from git_monitor.logger import logger
from git_monitor.tray_app import TrayApp
class Application:
def __init__(self):
logger.info("Application starting...")
self.tray_app = TrayApp()
def run(self):
try:
self.tray_app.run()
except KeyboardInterrupt:
logger.info("Application stopped by user.")
except Exception as e:
logger.error(f"Application error: {e}")
if __name__ == "__main__":
app = Application()
app.run()

93
git_monitor/notifier.py Normal file
View File

@@ -0,0 +1,93 @@
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:
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")
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()

View File

@@ -0,0 +1,7 @@
watchdog
GitPython
pystray
Pillow
plyer
win10toast
windows-toasts

104
git_monitor/tray_app.py Normal file
View File

@@ -0,0 +1,104 @@
import os
import threading
import tkinter as tk
from tkinter import filedialog
from PIL import Image, ImageDraw
import pystray
from pystray import MenuItem as item
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):
self.icon = None
self.watcher = None
self.root = tk.Tk()
self.root.withdraw() # Hide main tkinter window
# Load last used repo if exists
last_repo = config.repository_path
if last_repo:
if git_manager.load_repository(last_repo):
self.start_monitoring(last_repo)
def create_icon(self):
# Create a simple icon
width = 64
height = 64
image = Image.new('RGB', (width, height), color=(73, 109, 137))
dc = ImageDraw.Draw(image)
dc.rectangle([width // 4, height // 4, width * 3 // 4, height * 3 // 4], fill=(255, 255, 255))
menu = (
item('Git Monitor', lambda: None, enabled=False),
pystray.Menu.SEPARATOR,
item('Select Repository', self.select_repository),
item('Exit', self.exit_app),
)
self.icon = pystray.Icon("GitMonitor", image, "Git Monitor", menu)
def select_repository(self, icon=None, item=None):
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:
self.stop_monitoring()
self.watcher = RepositoryWatcher(path)
self.watcher.start()
notifier.notify("Git Monitor", f"Started monitoring: {os.path.basename(path)}")
def stop_monitoring(self):
if self.watcher:
self.watcher.stop()
self.watcher = None
notifier.notify("Git Monitor", "Stopped monitoring")
def exit_app(self, icon=None, item=None):
logger.info("Exiting application...")
self.stop_monitoring()
if self.icon:
self.icon.stop()
self.root.quit()
os._exit(0) # Ensure all threads terminate
def run(self):
self.create_icon()
# 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()
app.run()

135
main.py
View File

@@ -1,135 +0,0 @@
import time
import os
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from git import Repo
# KONFIGURACJA
PATH_TO_REPO = './moje-repo' # Ścieżka do Twojego katalogu git
COMMIT_MESSAGE = "Automatyczny commit: zmiana w pliku"
import pathspec
from watchdog.events import FileSystemEventHandler
class GitAutoCommitHandler(FileSystemEventHandler):
def __init__(self, repo_path):
self.repo_path = os.path.abspath(repo_path)
self.repo = Repo(self.repo_path)
self.spec = self._load_gitignore()
# Słownik do przechowywania czasu ostatniego commitu dla każdego pliku
self.last_committed = {}
self.debounce_seconds = 0.5 # Minimalny odstęp między commitami dla jednego pliku
def _load_gitignore(self):
gitignore_path = os.path.join(self.repo_path, '.gitignore')
if os.path.exists(gitignore_path):
with open(gitignore_path, 'r') as f:
# Tworzymy wzorzec dopasowania na podstawie linii w pliku
return pathspec.PathSpec.from_lines('gitwildmatch', f)
return None
def _is_ignored(self, file_path):
# Sprawdzamy ścieżkę względem głównego folderu repozytorium
relative_path = os.path.relpath(file_path, self.repo_path)
# Zawsze ignoruj folder .git
if ".git" in relative_path.split(os.sep):
return True
if self.spec and self.spec.match_file(relative_path):
return True
return False
def on_modified(self, event):
if not event.is_directory and not self._is_ignored(event.src_path):
self._process_event(event.src_path, "update-file")
def on_created(self, event):
if not event.is_directory and not self._is_ignored(event.src_path):
self._process_event(event.src_path, "new-file")
def on_deleted(self, event):
if not event.is_directory:
self._process_event(event.src_path, "remove-file")
def on_moved(self, event):
if not event.is_directory:
# Sprawdzamy, czy nowa lokalizacja nie jest ignorowana
if self._is_ignored(event.dest_path):
# Jeśli przenieśliśmy plik do folderu ignorowanego, traktujemy to jak usunięcie
self._process_event(event.src_path, "remove-file")
else:
self._process_rename(event.src_path, event.dest_path)
def _process_event(self, file_path, action_label):
if self._is_ignored(file_path):
return
current_time = time.time()
last_time = self.last_committed.get(file_path, 0)
# Sprawdź, czy minęło dość czasu od ostatniego commitu tego pliku
if current_time - last_time > self.debounce_seconds:
self.last_committed[file_path] = current_time
self._commit(file_path, action_label)
def _process_rename(self, old_path, new_path):
current_time = time.time()
# Używamy nowej ścieżki jako klucza do debounce
last_time = self.last_committed.get(new_path, 0)
if current_time - last_time > self.debounce_seconds:
self.last_committed[new_path] = current_time
try:
old_rel = os.path.relpath(old_path, self.repo_path)
new_rel = os.path.relpath(new_path, self.repo_path)
# Git sam wykrywa rename, jeśli dodamy oba pliki (usunięty i nowy)
# Ale możemy to zrobić jawnie dla czystości:
self.repo.index.remove([old_rel], working_tree=False)
self.repo.index.add([new_rel])
if self.repo.index.diff("HEAD"):
self.repo.index.commit(f"rename-file: {old_rel} -> {new_rel}")
print(f"🔄 RENAME: {old_rel} -> {new_rel}")
except Exception as e:
print(f"⚠️ Błąd podczas zmiany nazwy: {e}")
def _commit(self, file_path, action_label):
try:
relative_path = os.path.relpath(file_path, self.repo_path)
if action_label == "remove-file":
self.repo.index.remove([relative_path])
else:
self.repo.index.add([relative_path])
if self.repo.index.diff("HEAD"):
self.repo.index.commit(f"{action_label}: {relative_path}")
print(f"{action_label}: {relative_path}")
# if self.repo.is_dirty(path=relative_path) or relative_path in self.repo.untracked_files:
# self.repo.index.add([relative_path])
# self.repo.index.commit(f"Auto-commit: {relative_path}")
# print(f"✅ Zapisano: {relative_path}")
except Exception as e:
print(f"❌ Błąd: {e}")
if __name__ == "__main__":
if not os.path.exists(os.path.join(PATH_TO_REPO, ".git")):
print("Błąd: Wskazany katalog nie jest repozytorium Gita!")
else:
event_handler = GitAutoCommitHandler(PATH_TO_REPO)
observer = Observer()
observer.schedule(event_handler, PATH_TO_REPO, recursive=True)
print(f"🚀 Śledzenie katalogu: {PATH_TO_REPO}...")
observer.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()

5
run.py Normal file
View File

@@ -0,0 +1,5 @@
from git_monitor.main import Application
if __name__ == "__main__":
app = Application()
app.run()

View File

@@ -1,153 +0,0 @@
import os
import time
import threading
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from git import Repo
import pathspec
from plyer import notification
from pystray import Icon, Menu, MenuItem
from PIL import Image, ImageDraw
import logging
# Logi będą zapisywane w tym samym folderze co skrypt
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "debug_log.txt")
logging.basicConfig(
filename=log_path,
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
# --- TWOJA KLASA GIT (Z POPRZEDNICH KROKÓW) ---
class GitAutoCommitHandler(FileSystemEventHandler):
def __init__(self, repo_path):
self.repo_path = os.path.abspath(repo_path)
self.repo = Repo(self.repo_path)
self.spec = self._load_gitignore()
self.last_committed = {}
self.debounce_seconds = 3.0
def _load_gitignore(self):
gitignore_path = os.path.join(self.repo_path, '.gitignore')
if os.path.exists(gitignore_path):
with open(gitignore_path, 'r') as f:
return pathspec.PathSpec.from_lines('gitwildmatch', f)
return None
def _is_ignored(self, file_path):
abs_file_path = os.path.abspath(file_path)
relative_path = os.path.relpath(abs_file_path, self.repo_path)
if ".git" in relative_path.split(os.sep):
logging.debug(f"Ignorowanie (folder .git): {relative_path}")
return True
return self.spec.match_file(relative_path) if self.spec else False
def on_created(self, event):
if not event.is_directory: self._process_event(event.src_path, "new-file")
def on_modified(self, event):
if not event.is_directory: self._process_event(event.src_path, "update-file")
def on_deleted(self, event):
if not event.is_directory: self._process_event(event.src_path, "remove-file")
def on_moved(self, event):
if not event.is_directory: self._process_rename(event.src_path, event.dest_path)
def _process_event(self, file_path, action):
abs_path = os.path.abspath(file_path)
if self._is_ignored(abs_path):
return
logging.debug(f"Zdarzenie: {action} - {file_path}")
now = time.time()
if now - self.last_committed.get(abs_path, 0) > self.debounce_seconds:
self.last_committed[abs_path] = now
self._commit(abs_path, action)
def _process_rename(self, old, new):
if self._is_ignored(new): return
rel_old, rel_new = os.path.relpath(old, self.repo_path), os.path.relpath(new, self.repo_path)
self.repo.index.remove([rel_old], working_tree=False)
self.repo.index.add([rel_new])
self._finalize_commit(f"rename-file: {rel_old} -> {rel_new}", rel_new)
def _commit(self, file_path, action):
logging.debug(f"Commitowanie: {action} - {file_path}")
rel_path = os.path.relpath(file_path, self.repo_path)
if action == "remove-file":
self.repo.index.remove([rel_path])
else:
self.repo.index.add([rel_path])
self._finalize_commit(f"{action}: {rel_path}", rel_path)
# def _finalize_commit(self, msg, display_name):
# if self.repo.index.diff("HEAD"):
# self.repo.index.commit(msg)
# # POWIADOMIENIE WINDOWS
# notification.notify(
# title="Git Auto-Commit",
# message=f"Zapisano zmianę: {display_name}",
# app_name="Git Watcher",
# timeout=3
# )
def _finalize_commit(self, msg, display_name):
try:
if self.repo.is_dirty(untracked_files=True): # Dokładniejsze sprawdzenie
self.repo.index.commit(msg)
logging.info(f"✅ COMMIT SUCCESS: {msg}")
# Próba powiadomienia z łapaniem błędów
try:
notification.notify(
title="Git Auto-Commit",
message=f"Zapisano: {display_name}",
timeout=5
)
except Exception as e:
logging.error(f"Błąd powiadomienia: {e}")
except Exception as e:
logging.error(f"Błąd finalizacji commitu: {e}")
# --- LOGIKA TRAY I URUCHAMIANIA ---
def create_image():
# Generuje prostą ikonę (niebieskie koło), jeśli nie masz pliku .ico
width, height = 64, 64
image = Image.new('RGB', (width, height), (255, 255, 255))
dc = ImageDraw.Draw(image)
dc.ellipse([10, 10, 54, 54], fill=(0, 122, 204))
return image
def run_watcher(path, stop_event):
event_handler = GitAutoCommitHandler(path)
observer = Observer()
observer.schedule(event_handler, path, recursive=True)
observer.start()
while not stop_event.is_set():
time.sleep(1)
observer.stop()
observer.join()
def main():
logging.info("Aplikacja wystartowała")
repo_path = "./moje-repo" # Zmień na swoją ścieżkę
logging.info("Repozytorium: %s", repo_path)
stop_event = threading.Event()
# Uruchomienie obserwatora w osobnym wątku
watcher_thread = threading.Thread(target=run_watcher, args=(repo_path, stop_event))
watcher_thread.daemon = True
watcher_thread.start()
# Menu zasobnika
def on_quit(icon, item):
stop_event.set()
icon.stop()
icon = Icon("GitWatcher", create_image(), menu=Menu(
MenuItem(f"Śledzę: {os.path.abspath(repo_path)}", None, enabled=False),
MenuItem("Zakończ", on_quit)
))
icon.run()
if __name__ == "__main__":
main()