├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── requirements.txt ├── service ├── backup_manager.py ├── cmd_manager.py ├── commit_message_generation.py ├── constants.py ├── flag_helper.py ├── git_manager.py ├── path_translator.py ├── synchronizer_service.py └── tracking_state.py ├── setup ├── dotfiles_synchronizer.service └── targets.json └── systemd_setup.sh /.gitignore: -------------------------------------------------------------------------------- 1 | save.json 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alexander Karpukhin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Dotfiles Synchronizer

2 | 3 | Automatically synchronize any of your configurations to git. A working example can be seen [here](https://github.com/TheAlexDev23/dotfiles) 4 | 5 | ## Installation 6 | 7 | 1. **Fork** this repository 8 | 2. Clone the forked repo (preferably with ssh, as this program will also automatically push and you won't be able to input password/passkey) 9 | 3. Run systemd_setup.sh (if you have systemd) 10 | 4. Profit 11 | 12 | You can configure the directories or inidividual files that you want to synchronize by modifying `~/.config/synchronization_targets.json` 13 | 14 | The syntax is quite simple: 15 | 16 | ```json 17 | { 18 | "directories": [ 19 | "~/path/to/your/dir1", 20 | "~/path/to/your/dir2", 21 | "~/path/to/your/dir3" 22 | ], 23 | "files": [ 24 | "~/path/to/your/file.1", 25 | "~/path/to/your/file.2", 26 | "~/path/to/your/file.3" 27 | ] 28 | } 29 | ``` 30 | 31 | ## Other configuration 32 | 33 | synchronizer_service.py: 34 | ```python 35 | # Polling rate for file changes in seconds. Isn't as important, just make sure that it's not 0 if you use editors like neovim. 36 | RATE = 0.5 37 | 38 | VERBOSE_LOGGING = False 39 | 40 | # Mainly used in development. If False, will not commit/push just log. 41 | COMMIT = True 42 | 43 | # Time since last commit in order to push. Used to prevent rate limits. 44 | PUSH_RATE = 30 45 | 46 | # Experimental. Use GPT for commit messages. Requires OPENAI_KEY environment variable 47 | USE_OPENAI = False 48 | ``` 49 | 50 | 51 | ## What if I don't have systemd? 52 | I don't really have experience with systemd alternatives, but feel free to open a pr. 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pyright] 2 | include = ["./service/"] 3 | 4 | reportMissingImports = true 5 | reportMissingTypeStubs = false 6 | 7 | pythonVersion = "3.11" 8 | pythonPlatform = "Linux" 9 | 10 | executionEnvironments = [ 11 | { root = "./service/", pythonVersion = "3.11", extraPaths = [ "./service/" ] } 12 | ] 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | inotify_simple 2 | openai 3 | -------------------------------------------------------------------------------- /service/backup_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import path_translator 4 | import tracking_state 5 | 6 | from cmd_manager import exec_cmd 7 | 8 | 9 | def backup_all_files(): 10 | for path in tracking_state.base_paths: 11 | if os.path.isdir(path): 12 | backup_directory(path) 13 | else: 14 | backup_file(path) 15 | 16 | 17 | def backup_directory(dir: str) -> None: 18 | print(f"Backing up directory {dir}") 19 | 20 | backup_dir = path_translator.to_backup_path(dir) 21 | upper_backup_dir = os.path.join(backup_dir, "..") 22 | 23 | exec_cmd(f"mkdir -p {backup_dir}") 24 | exec_cmd(f"cp -r {dir} {upper_backup_dir}") 25 | 26 | 27 | def backup_file(path: str) -> None: 28 | print(f"Backing up file {path}") 29 | 30 | backup_dir = path_translator.to_backup_path(path) 31 | 32 | exec_cmd(f"mkdir -p {backup_dir}") 33 | exec_cmd(f"cp {path} {backup_dir}") 34 | 35 | 36 | def delete_backed_up_path(path: str): 37 | path = path_translator.to_backup_path(path) 38 | exec_cmd(f"rm -rf {path}") 39 | -------------------------------------------------------------------------------- /service/cmd_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | import constants 5 | 6 | 7 | def exec_cmd(cmd: str) -> None: 8 | if constants.NO_BACKUP: 9 | print(cmd) 10 | else: 11 | subprocess.run(cmd, shell=True, cwd=os.getcwd()) 12 | -------------------------------------------------------------------------------- /service/commit_message_generation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | from openai import OpenAI 5 | 6 | key = os.environ.get("OPENAI_KEY") 7 | 8 | if key is not None: 9 | client = OpenAI(api_key=os.environ.get("OPENAI_KEY")) 10 | 11 | 12 | def get_commit_message(model="gpt-3.5-turbo") -> str: 13 | diff = os.popen("git diff --cached").read() 14 | 15 | if diff is None: 16 | return "No changes" 17 | 18 | return ( 19 | client.chat.completions.create( 20 | model=model, 21 | messages=[ 22 | { 23 | "role": "system", 24 | "content": "I want you to act as the author of a commit message in git. I'll enter a git diff, and your job is to convert it into a useful commit message. Do not preface the commit with anything, use the present tense, return the full sentence. Write only title, max 72 characters.", 25 | }, 26 | {"role": "user", "content": diff}, 27 | ], 28 | temperature=1, 29 | max_tokens=256, 30 | top_p=1, 31 | frequency_penalty=0, 32 | presence_penalty=0, 33 | ) 34 | .choices[0] 35 | .message.content.capitalize() 36 | ) 37 | -------------------------------------------------------------------------------- /service/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | RATE = 0.5 4 | 5 | VERBOSE_LOGGING = False 6 | NO_BACKUP = True 7 | PUSH_RATE = 30 8 | 9 | USE_OPENAI = False 10 | 11 | HOME = os.environ.get("HOME") 12 | if HOME is None: 13 | print("HOME environment variable is nonexistent") 14 | exit(1) 15 | 16 | CONFIG = HOME + "/.config/synchronization_targets.json" 17 | -------------------------------------------------------------------------------- /service/flag_helper.py: -------------------------------------------------------------------------------- 1 | from inotify_simple import flags 2 | 3 | 4 | def has_ignored_flag(change): 5 | for flag in flags.from_mask(change.mask): 6 | if flag is flags.IGNORED: 7 | return True 8 | 9 | return False 10 | 11 | 12 | def print_flags(change): 13 | for flag in flags.from_mask(change.mask): 14 | print(next((flag.name for _flag in flags if _flag.value == flag), None)) # type: ignore 15 | 16 | 17 | def deleted_path(change) -> bool: 18 | for flag in flags.from_mask(change.mask): 19 | if flag is flags.DELETE_SELF: 20 | return True 21 | 22 | return False 23 | 24 | 25 | def deleted_subfile(change) -> bool: 26 | for flag in flags.from_mask(change.mask): 27 | if flag is flags.DELETE: 28 | return True 29 | 30 | return False 31 | -------------------------------------------------------------------------------- /service/git_manager.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | 4 | from cmd_manager import exec_cmd 5 | import constants 6 | import commit_message_generation 7 | 8 | awaiting_push = False 9 | time_since_awaiting_push = 0 10 | 11 | 12 | def commit(commit_message: str | None = None): 13 | global awaiting_push, time_since_awaiting_push 14 | 15 | awaiting_push = True 16 | time_since_awaiting_push = time.time() 17 | 18 | if commit_message is None: 19 | commit_message = str(datetime.now()) 20 | 21 | exec_cmd("git add .") 22 | 23 | if constants.USE_OPENAI: 24 | commit_message = commit_message_generation.get_commit_message() 25 | 26 | exec_cmd(f'git commit -m "Automated Backup: {commit_message}"') 27 | 28 | 29 | def push_if_necessary(): 30 | if time.time() - time_since_awaiting_push >= constants.PUSH_RATE: 31 | _git_push() 32 | 33 | 34 | def _git_push(): 35 | global awaiting_push 36 | awaiting_push = False 37 | 38 | exec_cmd("git push") 39 | -------------------------------------------------------------------------------- /service/path_translator.py: -------------------------------------------------------------------------------- 1 | from ctypes import ArgumentError 2 | import constants 3 | 4 | 5 | def to_backup_path(path: str) -> str: 6 | # to make pyright stfu 7 | assert constants.HOME is not None 8 | 9 | if not path.startswith(constants.HOME): 10 | raise ArgumentError("Path not located in /home/user are not supported") 11 | 12 | return path.replace(constants.HOME, "../home") 13 | -------------------------------------------------------------------------------- /service/synchronizer_service.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import os 5 | import json 6 | import time 7 | 8 | from inotify_simple import INotify, flags 9 | 10 | import flag_helper 11 | import constants 12 | import path_translator 13 | import git_manager 14 | import tracking_state 15 | import backup_manager 16 | 17 | 18 | config_notify: INotify 19 | dotfiles_notify: INotify 20 | 21 | 22 | def track_config(): 23 | global config_notify 24 | config_notify = INotify() 25 | watch_flags = flags.CREATE | flags.DELETE | flags.MODIFY | flags.DELETE_SELF 26 | 27 | config_notify.add_watch(constants.CONFIG, watch_flags) 28 | 29 | 30 | def backup_initial_files(): 31 | print("Performing initial backup") 32 | 33 | backup_manager.backup_all_files() 34 | 35 | git_manager.commit("initial backup") 36 | 37 | 38 | def check_for_changes(): 39 | global dotfiles_notify, config_notify 40 | 41 | while True: 42 | check_config_changes() 43 | check_tracked_files() 44 | 45 | git_manager.push_if_necessary() 46 | 47 | time.sleep(constants.RATE) 48 | 49 | 50 | def parse_config(exit_on_failure=True): 51 | try: 52 | with open(constants.CONFIG, "r") as fp: 53 | data = json.load(fp) 54 | except: 55 | sys.stderr.write("Could not load config.\n") 56 | if exit_on_failure: 57 | exit(1) 58 | else: 59 | # Gotta exit and keep previous dotfiles_notify because we wont be able to parse data 60 | return 61 | 62 | track_dotfiles(data) 63 | 64 | 65 | def track_dotfiles(data) -> None: 66 | global dotfiles_notify 67 | dotfiles_notify = INotify() 68 | 69 | tracking_state.all_tracked_paths = {} 70 | tracking_state.base_paths = {} 71 | 72 | for path in data["directories"]: 73 | path = os.path.expanduser(path) 74 | base_path = path 75 | print(f"Watching {path}") 76 | add_watch(path, base_path) 77 | 78 | for root, dirs, _ in os.walk(path): 79 | for directory in dirs: 80 | path = os.path.join(root, directory) 81 | print(f"Watching {path}") 82 | add_watch(path, base_path) 83 | 84 | for path in data["files"]: 85 | path = os.path.expanduser(path) 86 | print(f"Watching {path}") 87 | add_watch(path, path) 88 | 89 | 90 | def add_watch(path: str, base_path: str) -> None: 91 | watch_flags = ( 92 | flags.CREATE 93 | | flags.DELETE 94 | | flags.MODIFY 95 | | flags.DELETE_SELF 96 | | flags.MOVED_FROM 97 | | flags.MOVED_TO 98 | ) 99 | 100 | global dotfiles_notify 101 | 102 | try: 103 | wd = dotfiles_notify.add_watch(path, watch_flags) 104 | except FileNotFoundError: 105 | print(f"Could not track {path}, file doesn't exist") 106 | return 107 | 108 | add_to_base_paths(base_path, wd) 109 | tracking_state.all_tracked_paths[wd] = path 110 | 111 | 112 | def add_to_base_paths(path: str, wd): 113 | if path not in tracking_state.base_paths: 114 | tracking_state.base_paths[path] = [] 115 | 116 | tracking_state.base_paths[path].append(wd) 117 | 118 | 119 | def check_config_changes(): 120 | global config_notify 121 | 122 | changes = config_notify.read(0, 1) 123 | 124 | if len(changes) == 0: 125 | return 126 | 127 | for change in changes: 128 | if flag_helper.has_ignored_flag(change): 129 | track_config() 130 | 131 | parse_config(False) 132 | backup_manager.backup_all_files() 133 | git_manager.commit("synchronization targets changed") 134 | 135 | 136 | def check_tracked_files(): 137 | global dotfiles_notify 138 | 139 | changes = dotfiles_notify.read(0, 1) 140 | 141 | if len(changes) == 0: 142 | return 143 | 144 | # Paths to delete before applying changed_paths. Used when subfiles are deleted. 145 | force_change = set() 146 | changed_paths = set() 147 | deleted_paths = set() 148 | 149 | for change in changes: 150 | base_path = get_base_path(change.wd) 151 | print(f"Detected changes in {base_path}") 152 | 153 | if flag_helper.deleted_path(change): 154 | deleted_paths.add(base_path) 155 | else: 156 | changed_paths.add(base_path) 157 | if flag_helper.deleted_subfile(change): 158 | force_change.add(base_path) 159 | 160 | if constants.VERBOSE_LOGGING: 161 | print(f"Change {change}") 162 | flag_helper.print_flags(change) 163 | 164 | retrack_file_if_necessary(change, base_path) 165 | 166 | for change in force_change: 167 | backup_manager.delete_backed_up_path(change) 168 | 169 | for change in changed_paths: 170 | if os.path.isdir(change): 171 | backup_manager.backup_directory(change) 172 | else: 173 | backup_manager.backup_file(change) 174 | 175 | for deletion in deleted_paths: 176 | backup_manager.delete_backed_up_path(deletion) 177 | 178 | commit_message = "\nChanges to:" 179 | for change in changed_paths: 180 | commit_message += " " + path_translator.to_backup_path(change) 181 | commit_message += "\nDeleted: " 182 | for deletion in deleted_paths: 183 | commit_message += " " + path_translator.to_backup_path(deletion) 184 | 185 | git_manager.commit(commit_message) 186 | 187 | 188 | def get_base_path(wd) -> str: 189 | for key, value in tracking_state.base_paths.items(): 190 | if wd in value: 191 | return key 192 | 193 | raise ValueError("Invalid argument") 194 | 195 | 196 | def retrack_file_if_necessary(change, base_path): 197 | # I don't really understand how the ignored flag works, I just know that changes 198 | # with it need to be retracked or they won't be tracked anymore 199 | if not flag_helper.has_ignored_flag(change): 200 | return 201 | 202 | path = tracking_state.all_tracked_paths[change.wd] 203 | 204 | # Sometimes deleted files also get marked with IGNORED, so retracking them will be a no no 205 | if not os.path.exists(path): 206 | return 207 | 208 | add_watch(path, base_path) 209 | 210 | 211 | if __name__ == "__main__": 212 | parse_config() 213 | track_config() 214 | 215 | backup_initial_files() 216 | check_for_changes() 217 | -------------------------------------------------------------------------------- /service/tracking_state.py: -------------------------------------------------------------------------------- 1 | base_paths = {} 2 | all_tracked_paths = {} 3 | -------------------------------------------------------------------------------- /setup/dotfiles_synchronizer.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Automatical dotfiles git synchronizer 3 | After=network.target 4 | 5 | [Service] 6 | Environment="HOME=user_home" 7 | ExecStart=/usr/bin/env python script_path 8 | WorkingDirectory=script_directory 9 | Restart=always 10 | 11 | [Install] 12 | WantedBy=default.target 13 | 14 | -------------------------------------------------------------------------------- /setup/targets.json: -------------------------------------------------------------------------------- 1 | { 2 | "directories": [ 3 | ], 4 | "files": [ 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /systemd_setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | user_home=$HOME 4 | script_path=$(realpath -s ./service/synchronizer_service.py) 5 | script_directory=$(pwd)/service 6 | 7 | escaped_user_home=$(echo "$user_home" | sed 's/[\/&]/\\&/g') 8 | escaped_script_path=$(echo "$script_path" | sed 's/[\/&]/\\&/g') 9 | escaped_script_directory=$(echo "$script_directory" | sed 's/[\/&]/\\&/g') 10 | 11 | sed -i "s/user_home/$escaped_user_home/g" ./setup/dotfiles_synchronizer.service 12 | sed -i "s/script_path/$escaped_script_path/g" ./setup/dotfiles_synchronizer.service 13 | sed -i "s/script_directory/$escaped_script_directory/g" ./setup/dotfiles_synchronizer.service 14 | 15 | cp ./setup/targets.json $HOME/.config/synchronization_targets.json 16 | 17 | cp ./setup/dotfiles_synchronizer.service $HOME/.config/systemd/user 18 | systemctl --user daemon-reload 19 | 20 | read -p "Do you want to enable the daemon right away? (y/n): " answer 21 | 22 | if [ "$answer" == "y" ]; then 23 | systemctl --user start dotfiles_synchronizer 24 | systemctl --user enable dotfiles_synchronizer 25 | elif [ "$answer" != "n" ]; then 26 | echo "Invalid input. Please enter 'y' or 'n'." 27 | fi 28 | 29 | rm -r setup systemd_setup.sh 30 | --------------------------------------------------------------------------------