├── .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 |
--------------------------------------------------------------------------------