gitghost 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gitghost/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # GitGhost package init
gitghost/cli.py ADDED
@@ -0,0 +1,306 @@
1
+ import os
2
+ import shutil
3
+ import datetime
4
+ import click
5
+ from git import Repo, GitCommandError
6
+
7
+ GITVAULT_INCLUDE = ".gitghostinclude"
8
+ PRIVATE_REPO_DIR = ".gitghost_private"
9
+
10
+ def parse_gitghostinclude():
11
+ """Parse .gitghostinclude and return list of file paths."""
12
+ if not os.path.exists(GITVAULT_INCLUDE):
13
+ click.echo(f"Error: {GITVAULT_INCLUDE} not found.")
14
+ return []
15
+ with open(GITVAULT_INCLUDE, "r") as f:
16
+ lines = f.readlines()
17
+ files = []
18
+ for line in lines:
19
+ line = line.strip()
20
+ if not line or line.startswith("#"):
21
+ continue
22
+ files.append(line)
23
+ return files
24
+
25
+ def ensure_private_repo():
26
+ """Ensure the private repo exists and return Repo object."""
27
+ private_repo_path = os.path.join(os.getcwd(), PRIVATE_REPO_DIR)
28
+ if not os.path.exists(private_repo_path):
29
+ os.makedirs(private_repo_path, exist_ok=True)
30
+ repo = Repo.init(private_repo_path)
31
+ else:
32
+ try:
33
+ repo = Repo(private_repo_path)
34
+ except Exception:
35
+ repo = Repo.init(private_repo_path)
36
+ return repo
37
+
38
+ def copy_private_files(files):
39
+ """Copy private files into .gitghost_private/ preserving paths."""
40
+ for file_path in files:
41
+ src = os.path.join(os.getcwd(), file_path)
42
+ if not os.path.exists(src):
43
+ continue
44
+ dest = os.path.join(os.getcwd(), PRIVATE_REPO_DIR, file_path)
45
+ if os.path.isdir(src):
46
+ # Copy directory recursively
47
+ if os.path.exists(dest):
48
+ shutil.rmtree(dest)
49
+ shutil.copytree(src, dest)
50
+ else:
51
+ dest_dir = os.path.dirname(dest)
52
+ os.makedirs(dest_dir, exist_ok=True)
53
+ shutil.copy2(src, dest)
54
+
55
+ @click.group()
56
+ def cli():
57
+ """GitGhost CLI - manage private files securely."""
58
+ pass
59
+
60
+ @cli.command()
61
+ def status():
62
+ """Show status of private files."""
63
+ files = parse_gitghostinclude()
64
+ if not files:
65
+ return
66
+
67
+ # Copy private files into private repo dir
68
+ copy_private_files(files)
69
+
70
+ # Open or init private repo
71
+ repo = ensure_private_repo()
72
+
73
+ # If private repo has no commits, instruct user to run save
74
+ if not repo.head.is_valid():
75
+ click.echo("Private repo is empty. The following files/folders will be added on first save:")
76
+ for f in files:
77
+ click.echo(f" {f}")
78
+ click.echo("Run 'gitghost save' to create the initial commit of your private files.")
79
+ return
80
+
81
+ changed_files = []
82
+ import os as _os
83
+
84
+ for file_path in files:
85
+ vault_path = os.path.join(os.getcwd(), PRIVATE_REPO_DIR, file_path)
86
+ if not os.path.exists(vault_path):
87
+ click.echo(f"Missing in vault: {file_path}")
88
+ continue
89
+
90
+ paths_to_check = []
91
+ if os.path.isdir(vault_path):
92
+ # Recursively add all files inside directory
93
+ for root, dirs, filenames in _os.walk(vault_path):
94
+ for fname in filenames:
95
+ full_path = os.path.join(root, fname)
96
+ rel = os.path.relpath(full_path, os.path.join(os.getcwd(), PRIVATE_REPO_DIR))
97
+ paths_to_check.append(rel)
98
+ else:
99
+ rel = file_path
100
+ paths_to_check.append(rel)
101
+
102
+ try:
103
+ diff_index = repo.index.diff(None)
104
+ for rel_path in paths_to_check:
105
+ changed = any(d.a_path == rel_path for d in diff_index)
106
+ untracked = rel_path in repo.untracked_files
107
+ if changed or untracked:
108
+ changed_files.append(rel_path)
109
+ except GitCommandError:
110
+ continue
111
+
112
+ if changed_files:
113
+ click.echo("Changed private files:")
114
+ for f in changed_files:
115
+ click.echo(f" {f}")
116
+ else:
117
+ click.echo("No changes in private files.")
118
+
119
+ @cli.command()
120
+ @click.option('--message', '-m', default=None, help='Commit message')
121
+ def save(message):
122
+ """Save changes of private files to private repo."""
123
+ if not message:
124
+ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
125
+ message = f"GitGhost backup on {now}"
126
+ files = parse_gitghostinclude()
127
+ if not files:
128
+ return
129
+
130
+ import subprocess
131
+ private_repo_path = os.path.join(os.getcwd(), PRIVATE_REPO_DIR)
132
+ repo = Repo(private_repo_path)
133
+ if repo.head.is_valid():
134
+ subprocess.run(["git", "-C", private_repo_path, "reset", "--hard", "HEAD"], check=True)
135
+ else:
136
+ click.echo("Private repo is empty, skipping reset.")
137
+
138
+ # Copy private files into private repo dir
139
+ copy_private_files(files)
140
+
141
+ # Open or init private repo
142
+ repo = ensure_private_repo()
143
+
144
+ to_add = []
145
+ for file_path in files:
146
+ rel_path = file_path
147
+ abs_path = os.path.join(os.getcwd(), PRIVATE_REPO_DIR, rel_path)
148
+ if os.path.exists(abs_path):
149
+ to_add.append(rel_path)
150
+
151
+ if not to_add:
152
+ click.echo("No files to add.")
153
+ return
154
+
155
+ try:
156
+ repo.index.add(to_add)
157
+
158
+ # Always commit if repo has no commits yet
159
+ if not repo.head.is_valid() or repo.is_dirty(index=True, working_tree=True, untracked_files=True):
160
+ repo.index.commit(message)
161
+ origin = repo.remote(name='origin')
162
+ try:
163
+ origin.push()
164
+ except GitCommandError as e:
165
+ if "has no upstream branch" in str(e):
166
+ repo.git.push('--set-upstream', 'origin', repo.active_branch.name)
167
+ else:
168
+ raise
169
+ click.echo("Changes saved and pushed.")
170
+ else:
171
+ click.echo("No changes to commit.")
172
+ except GitCommandError as e:
173
+ click.echo(f"Git error: {e}")
174
+ @cli.command()
175
+ def discard():
176
+ """Restore private files/folders from the last private repo commit, discarding local changes."""
177
+ import shutil
178
+
179
+ confirm = input("This will OVERWRITE your private files/folders with the last saved private vault snapshot. Are you sure? (y/n): ").strip().lower()
180
+ if confirm not in ("y", "yes"):
181
+ click.echo("Discard cancelled.")
182
+ return
183
+
184
+ files = parse_gitghostinclude()
185
+ # Reset private repo working directory to last commit
186
+ import subprocess
187
+ subprocess.run(["git", "-C", os.path.join(os.getcwd(), PRIVATE_REPO_DIR), "reset", "--hard", "HEAD"], check=True)
188
+ for file_path in files:
189
+ src = os.path.join(os.getcwd(), PRIVATE_REPO_DIR, file_path)
190
+ dest = os.path.join(os.getcwd(), file_path)
191
+
192
+ if not os.path.exists(src):
193
+ click.echo(f"Skipping missing in vault: {file_path}")
194
+ continue
195
+
196
+ if os.path.isdir(src):
197
+ if os.path.exists(dest):
198
+ shutil.rmtree(dest)
199
+ shutil.copytree(src, dest)
200
+ click.echo(f"Restored directory: {file_path}")
201
+ else:
202
+ dest_dir = os.path.dirname(dest)
203
+ os.makedirs(dest_dir, exist_ok=True)
204
+ shutil.copy2(src, dest)
205
+ click.echo(f"Restored file: {file_path}")
206
+
207
+ click.echo("Private files/folders restored from private vault.")
208
+
209
+
210
+ @cli.command()
211
+ def init():
212
+ """Initialize GitGhost: create .gitghostinclude, .gitghost_private repo, and update .gitignore."""
213
+ # Create .gitghostinclude if missing
214
+ if not os.path.exists(GITVAULT_INCLUDE):
215
+ with open(GITVAULT_INCLUDE, "w") as f:
216
+ f.write("# List private files and folders, one per line\n")
217
+ click.echo(f"Created {GITVAULT_INCLUDE}")
218
+ else:
219
+ click.echo(f"{GITVAULT_INCLUDE} already exists.")
220
+
221
+ # Create .gitghost_private directory and init repo
222
+ private_repo_path = os.path.join(os.getcwd(), PRIVATE_REPO_DIR)
223
+ if not os.path.exists(private_repo_path):
224
+ os.makedirs(private_repo_path, exist_ok=True)
225
+ Repo.init(private_repo_path)
226
+ click.echo(f"Initialized private repo in {PRIVATE_REPO_DIR}")
227
+ else:
228
+ try:
229
+ Repo(private_repo_path)
230
+ click.echo(f"Private repo already exists in {PRIVATE_REPO_DIR}")
231
+ except Exception:
232
+ Repo.init(private_repo_path)
233
+ click.echo(f"Initialized private repo in {PRIVATE_REPO_DIR}")
234
+
235
+ # Add .gitghost_private/ to .gitignore if not present
236
+ # Attempt to create private GitHub repo automatically if gh CLI is available
237
+ import subprocess
238
+
239
+ def has_gh():
240
+ try:
241
+ subprocess.run(["gh", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
242
+ return True
243
+ except Exception:
244
+ return False
245
+
246
+ def get_public_repo_name():
247
+ try:
248
+ repo = Repo(os.getcwd())
249
+ url = next(repo.remote().urls)
250
+ name = url.split("/")[-1]
251
+ if name.endswith(".git"):
252
+ name = name[:-4]
253
+ return name
254
+ except Exception:
255
+ # fallback to current directory name
256
+ return os.path.basename(os.getcwd())
257
+
258
+ private_repo_url = None
259
+ if has_gh():
260
+ repo_name = get_public_repo_name() + "-gitghost"
261
+ try:
262
+ # Check if repo already exists
263
+ result = subprocess.run(["gh", "repo", "view", repo_name, "--json", "name"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
264
+ if result.returncode != 0:
265
+ # Repo does not exist, create it
266
+ subprocess.run(["gh", "repo", "create", repo_name, "--private", "-y"], check=True)
267
+ else:
268
+ click.echo(f"Private GitHub repo '{repo_name}' already exists.")
269
+ username = subprocess.check_output(["gh", "api", "user"], text=True)
270
+ import json as js
271
+ username = js.loads(username)["login"]
272
+ private_repo_url = f"https://github.com/{username}/{repo_name}.git"
273
+ repo = Repo(private_repo_path)
274
+ if "origin" not in [r.name for r in repo.remotes]:
275
+ repo.create_remote("origin", private_repo_url)
276
+ click.echo(f"Created and added remote: {private_repo_url}")
277
+ except Exception:
278
+ click.echo("Failed to create private GitHub repo automatically. You can create it manually and add as remote.")
279
+ else:
280
+ click.echo("GitHub CLI (gh) not found. Skipping automatic private repo creation.")
281
+ gitignore_path = os.path.join(os.getcwd(), ".gitignore")
282
+ vault_block = "# GitGhost\n.gitghost_private/\n.gitghostinclude\n"
283
+
284
+ def block_present(content):
285
+ return ".gitghost_private/" in content and ".gitghostinclude" in content
286
+
287
+ if os.path.exists(gitignore_path):
288
+ with open(gitignore_path, "r") as f:
289
+ content = f.read()
290
+ if not block_present(content):
291
+ content = content.rstrip()
292
+ if content != "":
293
+ content += "\n\n" # ensure one blank line before block
294
+ content += vault_block
295
+ with open(gitignore_path, "w") as f:
296
+ f.write(content)
297
+ click.echo("Added GitGhost block to .gitignore")
298
+ else:
299
+ click.echo("GitGhost block already present in .gitignore")
300
+ else:
301
+ with open(gitignore_path, "w") as f:
302
+ f.write(vault_block)
303
+ click.echo("Created .gitignore with GitGhost block")
304
+
305
+ if __name__ == "__main__":
306
+ cli()
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitghost
3
+ Version: 1.0.0
4
+ Summary: A CLI tool to securely manage private files/folders ignored in public git repositories.
5
+ Author: Decoding Chris
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Software Development :: Version Control
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: click<9.0,>=8.0
18
+ Requires-Dist: gitpython<4.0,>=3.1
19
+
20
+ # GitGhost
21
+
22
+ A simple CLI tool to securely manage private files and folders ignored in your public Git repositories.
23
+
24
+ ---
25
+
26
+ ## Why use GitGhost?
27
+
28
+ - **Keep sensitive files private:** Manage secrets, configs, or personal files outside your public repo.
29
+ - **Seamless Git integration:** Works alongside your existing Git workflow.
30
+ - **Simple commands:** Easily save, check status, or discard private changes.
31
+ - **Separate private repo:** Keeps your private data secure and versioned.
32
+ - **Cross-platform:** Designed for Linux, works on any system with Python 3.10+.
33
+
34
+ ---
35
+
36
+ ## Requirements
37
+
38
+ - **Python 3.10 or higher**
39
+ - Compatible with Ubuntu/Linux systems
40
+ - An existing Git repository
41
+
42
+ ---
43
+
44
+ ## Installation
45
+
46
+ Install GitGhost directly from PyPI:
47
+
48
+ ```bash
49
+ pip install gitghost
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Quick Start
55
+
56
+ Initialize GitGhost in your project:
57
+
58
+ ```bash
59
+ gitghost init
60
+ ```
61
+
62
+ Check status of private files:
63
+
64
+ ```bash
65
+ gitghost status
66
+ ```
67
+
68
+ Save private changes:
69
+
70
+ ```bash
71
+ gitghost save
72
+ ```
73
+
74
+ Discard private changes:
75
+
76
+ ```bash
77
+ gitghost discard
78
+ ```
79
+
80
+ ---
81
+
82
+ ## How it works
83
+
84
+ - Specify private files/folders in `.gitghostinclude` (which should also be in `.gitignore`).
85
+ - GitGhost manages a **separate private repository** for these files.
86
+ - `gitghost save` commits and pushes private changes.
87
+ - `gitghost status` shows private file changes.
88
+ - Keeps private data out of your public repo, but safely versioned.
89
+
90
+ ---
91
+
92
+ ## Links
93
+
94
+ - **PyPI:** (Coming soon)
95
+ - **Source Code:** [https://github.com/decodingchris/gitghost](https://github.com/decodingchris/gitghost)
96
+ - **Issue Tracker:** [https://github.com/decodingchris/gitghost/issues](https://github.com/decodingchris/gitghost/issues)
97
+
98
+ ---
99
+
100
+ ## License
101
+
102
+ This project is licensed under the **MIT License**. See the [LICENSE](https://opensource.org/licenses/MIT) file for details.
@@ -0,0 +1,7 @@
1
+ gitghost/__init__.py,sha256=tF7wf-6Dl5FQ3GubE9ucgDpkOkHyp1O6CsV6tZpifmY,23
2
+ gitghost/cli.py,sha256=wCx2Zeo3oQ9Ci47i-htk9D6lEHOMfWqKlQtZwY9xxGU,11256
3
+ gitghost-1.0.0.dist-info/METADATA,sha256=iCsjoPNUzouEd-DMLxZQt0n0ceabd8AMOfQ5SqaQsU4,2526
4
+ gitghost-1.0.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
5
+ gitghost-1.0.0.dist-info/entry_points.txt,sha256=RJW3gz58dSYB6jTf2mlprcU7H4OI_KGO_yHmJBIWU_Q,46
6
+ gitghost-1.0.0.dist-info/top_level.txt,sha256=mojEQo6pt-3lkeJxbiwqWxsm0Z7PMngvymEH863d73g,9
7
+ gitghost-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (78.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gitghost = gitghost.cli:cli
@@ -0,0 +1 @@
1
+ gitghost