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 +1 -0
- gitghost/cli.py +306 -0
- gitghost-1.0.0.dist-info/METADATA +102 -0
- gitghost-1.0.0.dist-info/RECORD +7 -0
- gitghost-1.0.0.dist-info/WHEEL +5 -0
- gitghost-1.0.0.dist-info/entry_points.txt +2 -0
- gitghost-1.0.0.dist-info/top_level.txt +1 -0
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 @@
|
|
1
|
+
gitghost
|