gitghost 1.0.0__tar.gz
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-1.0.0/PKG-INFO +102 -0
- gitghost-1.0.0/README.md +83 -0
- gitghost-1.0.0/gitghost/__init__.py +1 -0
- gitghost-1.0.0/gitghost/cli.py +306 -0
- gitghost-1.0.0/gitghost.egg-info/PKG-INFO +102 -0
- gitghost-1.0.0/gitghost.egg-info/SOURCES.txt +12 -0
- gitghost-1.0.0/gitghost.egg-info/dependency_links.txt +1 -0
- gitghost-1.0.0/gitghost.egg-info/entry_points.txt +2 -0
- gitghost-1.0.0/gitghost.egg-info/requires.txt +2 -0
- gitghost-1.0.0/gitghost.egg-info/top_level.txt +1 -0
- gitghost-1.0.0/pyproject.toml +31 -0
- gitghost-1.0.0/setup.cfg +4 -0
- gitghost-1.0.0/tests/test_integration.py +284 -0
- gitghost-1.0.0/tests/test_unit.py +273 -0
gitghost-1.0.0/PKG-INFO
ADDED
@@ -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.
|
gitghost-1.0.0/README.md
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
# GitGhost
|
2
|
+
|
3
|
+
A simple CLI tool to securely manage private files and folders ignored in your public Git repositories.
|
4
|
+
|
5
|
+
---
|
6
|
+
|
7
|
+
## Why use GitGhost?
|
8
|
+
|
9
|
+
- **Keep sensitive files private:** Manage secrets, configs, or personal files outside your public repo.
|
10
|
+
- **Seamless Git integration:** Works alongside your existing Git workflow.
|
11
|
+
- **Simple commands:** Easily save, check status, or discard private changes.
|
12
|
+
- **Separate private repo:** Keeps your private data secure and versioned.
|
13
|
+
- **Cross-platform:** Designed for Linux, works on any system with Python 3.10+.
|
14
|
+
|
15
|
+
---
|
16
|
+
|
17
|
+
## Requirements
|
18
|
+
|
19
|
+
- **Python 3.10 or higher**
|
20
|
+
- Compatible with Ubuntu/Linux systems
|
21
|
+
- An existing Git repository
|
22
|
+
|
23
|
+
---
|
24
|
+
|
25
|
+
## Installation
|
26
|
+
|
27
|
+
Install GitGhost directly from PyPI:
|
28
|
+
|
29
|
+
```bash
|
30
|
+
pip install gitghost
|
31
|
+
```
|
32
|
+
|
33
|
+
---
|
34
|
+
|
35
|
+
## Quick Start
|
36
|
+
|
37
|
+
Initialize GitGhost in your project:
|
38
|
+
|
39
|
+
```bash
|
40
|
+
gitghost init
|
41
|
+
```
|
42
|
+
|
43
|
+
Check status of private files:
|
44
|
+
|
45
|
+
```bash
|
46
|
+
gitghost status
|
47
|
+
```
|
48
|
+
|
49
|
+
Save private changes:
|
50
|
+
|
51
|
+
```bash
|
52
|
+
gitghost save
|
53
|
+
```
|
54
|
+
|
55
|
+
Discard private changes:
|
56
|
+
|
57
|
+
```bash
|
58
|
+
gitghost discard
|
59
|
+
```
|
60
|
+
|
61
|
+
---
|
62
|
+
|
63
|
+
## How it works
|
64
|
+
|
65
|
+
- Specify private files/folders in `.gitghostinclude` (which should also be in `.gitignore`).
|
66
|
+
- GitGhost manages a **separate private repository** for these files.
|
67
|
+
- `gitghost save` commits and pushes private changes.
|
68
|
+
- `gitghost status` shows private file changes.
|
69
|
+
- Keeps private data out of your public repo, but safely versioned.
|
70
|
+
|
71
|
+
---
|
72
|
+
|
73
|
+
## Links
|
74
|
+
|
75
|
+
- **PyPI:** (Coming soon)
|
76
|
+
- **Source Code:** [https://github.com/decodingchris/gitghost](https://github.com/decodingchris/gitghost)
|
77
|
+
- **Issue Tracker:** [https://github.com/decodingchris/gitghost/issues](https://github.com/decodingchris/gitghost/issues)
|
78
|
+
|
79
|
+
---
|
80
|
+
|
81
|
+
## License
|
82
|
+
|
83
|
+
This project is licensed under the **MIT License**. See the [LICENSE](https://opensource.org/licenses/MIT) file for details.
|
@@ -0,0 +1 @@
|
|
1
|
+
# GitGhost package init
|
@@ -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,12 @@
|
|
1
|
+
README.md
|
2
|
+
pyproject.toml
|
3
|
+
gitghost/__init__.py
|
4
|
+
gitghost/cli.py
|
5
|
+
gitghost.egg-info/PKG-INFO
|
6
|
+
gitghost.egg-info/SOURCES.txt
|
7
|
+
gitghost.egg-info/dependency_links.txt
|
8
|
+
gitghost.egg-info/entry_points.txt
|
9
|
+
gitghost.egg-info/requires.txt
|
10
|
+
gitghost.egg-info/top_level.txt
|
11
|
+
tests/test_integration.py
|
12
|
+
tests/test_unit.py
|
@@ -0,0 +1 @@
|
|
1
|
+
|
@@ -0,0 +1 @@
|
|
1
|
+
gitghost
|
@@ -0,0 +1,31 @@
|
|
1
|
+
[build-system]
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
3
|
+
build-backend = "setuptools.build_meta"
|
4
|
+
|
5
|
+
[project]
|
6
|
+
name = "gitghost"
|
7
|
+
version = "1.0.0"
|
8
|
+
description = "A CLI tool to securely manage private files/folders ignored in public git repositories."
|
9
|
+
authors = [
|
10
|
+
{ name = "Decoding Chris" }
|
11
|
+
]
|
12
|
+
license = "MIT"
|
13
|
+
readme = "README.md"
|
14
|
+
requires-python = ">=3.10"
|
15
|
+
dependencies = [
|
16
|
+
"click>=8.0,<9.0",
|
17
|
+
"gitpython>=3.1,<4.0"
|
18
|
+
]
|
19
|
+
classifiers = [
|
20
|
+
"Programming Language :: Python :: 3",
|
21
|
+
"Programming Language :: Python :: 3.10",
|
22
|
+
"Programming Language :: Python :: 3.11",
|
23
|
+
"Programming Language :: Python :: 3.12",
|
24
|
+
"Programming Language :: Python :: 3.13",
|
25
|
+
"Operating System :: POSIX :: Linux",
|
26
|
+
"Intended Audience :: Developers",
|
27
|
+
"Topic :: Software Development :: Version Control"
|
28
|
+
]
|
29
|
+
|
30
|
+
[project.scripts]
|
31
|
+
gitghost = "gitghost.cli:cli"
|
gitghost-1.0.0/setup.cfg
ADDED
@@ -0,0 +1,284 @@
|
|
1
|
+
import pytest
|
2
|
+
from click.testing import CliRunner
|
3
|
+
from gitghost.cli import cli
|
4
|
+
from unittest import mock
|
5
|
+
from git import GitCommandError
|
6
|
+
import os
|
7
|
+
|
8
|
+
@pytest.fixture
|
9
|
+
def runner():
|
10
|
+
return CliRunner()
|
11
|
+
|
12
|
+
def test_init_creates_include_and_repo(tmp_path, runner):
|
13
|
+
os.chdir(tmp_path)
|
14
|
+
result = runner.invoke(cli, ["init"])
|
15
|
+
assert result.exit_code == 0
|
16
|
+
assert ".gitghostinclude" in os.listdir()
|
17
|
+
assert ".gitghost_private" in os.listdir()
|
18
|
+
assert "Initialized private repo" in result.output or "already exists" in result.output
|
19
|
+
|
20
|
+
def test_init_when_include_exists(tmp_path, runner):
|
21
|
+
os.chdir(tmp_path)
|
22
|
+
(tmp_path / ".gitghostinclude").write_text("# test\n")
|
23
|
+
result = runner.invoke(cli, ["init"])
|
24
|
+
assert result.exit_code == 0
|
25
|
+
assert ".gitghostinclude" in os.listdir()
|
26
|
+
assert "already exists" in result.output
|
27
|
+
|
28
|
+
def test_save_no_files(tmp_path, runner):
|
29
|
+
os.chdir(tmp_path)
|
30
|
+
# No .gitghostinclude file
|
31
|
+
result = runner.invoke(cli, ["save"])
|
32
|
+
assert result.exit_code == 0 or result.exit_code == 1
|
33
|
+
# Should handle missing include gracefully
|
34
|
+
|
35
|
+
def test_save_with_message(tmp_path, runner):
|
36
|
+
os.chdir(tmp_path)
|
37
|
+
(tmp_path / ".gitghostinclude").write_text("secret.txt\n")
|
38
|
+
(tmp_path / "secret.txt").write_text("top secret")
|
39
|
+
# Initialize repo first
|
40
|
+
runner.invoke(cli, ["init"])
|
41
|
+
result = runner.invoke(cli, ["save", "--message", "my commit"])
|
42
|
+
assert result.exit_code in (0, 1)
|
43
|
+
|
44
|
+
def test_status_empty_include(tmp_path, runner):
|
45
|
+
os.chdir(tmp_path)
|
46
|
+
(tmp_path / ".gitghostinclude").write_text("")
|
47
|
+
runner.invoke(cli, ["init"])
|
48
|
+
result = runner.invoke(cli, ["status"])
|
49
|
+
assert result.exit_code in (0, 1)
|
50
|
+
|
51
|
+
def test_status_missing_include(tmp_path, runner):
|
52
|
+
os.chdir(tmp_path)
|
53
|
+
result = runner.invoke(cli, ["status"])
|
54
|
+
assert result.exit_code in (0, 1)
|
55
|
+
assert "not found" in result.output or "No changes" in result.output
|
56
|
+
|
57
|
+
def test_discard_cancel(tmp_path, runner):
|
58
|
+
os.chdir(tmp_path)
|
59
|
+
(tmp_path / ".gitghostinclude").write_text("secret.txt\n")
|
60
|
+
(tmp_path / "secret.txt").write_text("top secret")
|
61
|
+
runner.invoke(cli, ["init"])
|
62
|
+
# Simulate user entering 'n' to cancel discard
|
63
|
+
result = runner.invoke(cli, ["discard"], input="n\n")
|
64
|
+
assert result.exit_code in (0, 1)
|
65
|
+
assert "cancelled" in result.output or "Discard cancelled" in result.output
|
66
|
+
|
67
|
+
def test_discard_confirm(tmp_path, runner):
|
68
|
+
os.chdir(tmp_path)
|
69
|
+
(tmp_path / ".gitghostinclude").write_text("secret.txt\n")
|
70
|
+
(tmp_path / "secret.txt").write_text("top secret")
|
71
|
+
runner.invoke(cli, ["init"])
|
72
|
+
# Simulate user entering 'y' to confirm discard
|
73
|
+
result = runner.invoke(cli, ["discard"], input="y\n")
|
74
|
+
assert result.exit_code in (0, 1)
|
75
|
+
# Should attempt to restore files or print restored message
|
76
|
+
|
77
|
+
def test_status_empty_private_repo(monkeypatch):
|
78
|
+
repo_mock = mock.Mock()
|
79
|
+
repo_mock.head.is_valid.return_value = False
|
80
|
+
with mock.patch("gitghost.cli.ensure_private_repo", return_value=repo_mock), \
|
81
|
+
mock.patch("gitghost.cli.parse_gitghostinclude", return_value=["file1.txt"]), \
|
82
|
+
mock.patch("gitghost.cli.copy_private_files"):
|
83
|
+
runner = CliRunner()
|
84
|
+
result = runner.invoke(cli, ["status"])
|
85
|
+
assert "Private repo is empty" in result.output
|
86
|
+
|
87
|
+
def test_status_missing_files(monkeypatch):
|
88
|
+
repo_mock = mock.Mock()
|
89
|
+
repo_mock.head.is_valid.return_value = True
|
90
|
+
repo_mock.index.diff.return_value = []
|
91
|
+
repo_mock.untracked_files = []
|
92
|
+
with mock.patch("gitghost.cli.ensure_private_repo", return_value=repo_mock), \
|
93
|
+
mock.patch("gitghost.cli.parse_gitghostinclude", return_value=["file1.txt"]), \
|
94
|
+
mock.patch("gitghost.cli.copy_private_files"), \
|
95
|
+
mock.patch("os.path.exists", return_value=False):
|
96
|
+
runner = CliRunner()
|
97
|
+
result = runner.invoke(cli, ["status"])
|
98
|
+
assert "Missing in vault" in result.output
|
99
|
+
|
100
|
+
def test_status_directory_recursion(monkeypatch):
|
101
|
+
repo_mock = mock.Mock()
|
102
|
+
repo_mock.head.is_valid.return_value = True
|
103
|
+
repo_mock.index.diff.return_value = []
|
104
|
+
repo_mock.untracked_files = []
|
105
|
+
with mock.patch("gitghost.cli.ensure_private_repo", return_value=repo_mock), \
|
106
|
+
mock.patch("gitghost.cli.parse_gitghostinclude", return_value=["dir1"]), \
|
107
|
+
mock.patch("gitghost.cli.copy_private_files"), \
|
108
|
+
mock.patch("os.path.exists", return_value=True), \
|
109
|
+
mock.patch("os.path.isdir", return_value=True), \
|
110
|
+
mock.patch("os.walk", return_value=[("/vault/dir1", [], ["a.txt", "b.txt"])]):
|
111
|
+
runner = CliRunner()
|
112
|
+
result = runner.invoke(cli, ["status"])
|
113
|
+
# Should not crash, may or may not print files
|
114
|
+
|
115
|
+
def test_status_git_command_error(monkeypatch):
|
116
|
+
repo_mock = mock.Mock()
|
117
|
+
repo_mock.head.is_valid.return_value = True
|
118
|
+
repo_mock.index.diff.side_effect = GitCommandError("diff", 1)
|
119
|
+
repo_mock.untracked_files = []
|
120
|
+
with mock.patch("gitghost.cli.ensure_private_repo", return_value=repo_mock), \
|
121
|
+
mock.patch("gitghost.cli.parse_gitghostinclude", return_value=["file1.txt"]), \
|
122
|
+
mock.patch("gitghost.cli.copy_private_files"), \
|
123
|
+
mock.patch("os.path.exists", return_value=True):
|
124
|
+
runner = CliRunner()
|
125
|
+
result = runner.invoke(cli, ["status"])
|
126
|
+
# Should handle error gracefully, no crash
|
127
|
+
|
128
|
+
def test_status_no_changes(monkeypatch):
|
129
|
+
repo_mock = mock.Mock()
|
130
|
+
repo_mock.head.is_valid.return_value = True
|
131
|
+
repo_mock.index.diff.return_value = []
|
132
|
+
repo_mock.untracked_files = []
|
133
|
+
with mock.patch("gitghost.cli.ensure_private_repo", return_value=repo_mock), \
|
134
|
+
mock.patch("gitghost.cli.parse_gitghostinclude", return_value=["file1.txt"]), \
|
135
|
+
mock.patch("gitghost.cli.copy_private_files"), \
|
136
|
+
mock.patch("os.path.exists", return_value=True):
|
137
|
+
runner = CliRunner()
|
138
|
+
result = runner.invoke(cli, ["status"])
|
139
|
+
# Should not print "Changed private files"
|
140
|
+
def test_init_github_cli_not_installed(monkeypatch):
|
141
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
142
|
+
monkeypatch.setattr("os.path.exists", lambda p: False)
|
143
|
+
with mock.patch("gitghost.cli.Repo.init"), \
|
144
|
+
mock.patch("gitghost.cli.open", mock.mock_open(), create=True), \
|
145
|
+
mock.patch("subprocess.run", side_effect=FileNotFoundError):
|
146
|
+
runner = CliRunner()
|
147
|
+
result = runner.invoke(cli, ["init"])
|
148
|
+
assert "GitHub CLI (gh) not found" in result.output
|
149
|
+
|
150
|
+
def test_init_github_repo_creation_failure(monkeypatch):
|
151
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
152
|
+
monkeypatch.setattr("os.path.exists", lambda p: False)
|
153
|
+
def subprocess_run_side_effect(*args, **kwargs):
|
154
|
+
if "gh" in args[0]:
|
155
|
+
raise Exception("gh error")
|
156
|
+
return mock.DEFAULT
|
157
|
+
with mock.patch("gitghost.cli.Repo.init"), \
|
158
|
+
mock.patch("gitghost.cli.open", mock.mock_open(), create=True), \
|
159
|
+
mock.patch("subprocess.run", side_effect=subprocess_run_side_effect):
|
160
|
+
runner = CliRunner()
|
161
|
+
result = runner.invoke(cli, ["init"])
|
162
|
+
assert "Failed to create private GitHub repo automatically" in result.output or "GitHub CLI (gh) not found" in result.output
|
163
|
+
|
164
|
+
def test_init_gitignore_missing(monkeypatch, tmp_path):
|
165
|
+
os.chdir(tmp_path)
|
166
|
+
(tmp_path / ".gitghostinclude").write_text("secret.txt\n")
|
167
|
+
(tmp_path / "secret.txt").write_text("top secret")
|
168
|
+
with mock.patch("gitghost.cli.Repo.init"), \
|
169
|
+
mock.patch("subprocess.run", side_effect=FileNotFoundError):
|
170
|
+
runner = CliRunner()
|
171
|
+
result = runner.invoke(cli, ["init"])
|
172
|
+
assert ".gitignore" in os.listdir()
|
173
|
+
|
174
|
+
def test_init_gitignore_exists_without_block(monkeypatch, tmp_path):
|
175
|
+
os.chdir(tmp_path)
|
176
|
+
(tmp_path / ".gitghostinclude").write_text("secret.txt\n")
|
177
|
+
(tmp_path / "secret.txt").write_text("top secret")
|
178
|
+
(tmp_path / ".gitignore").write_text("node_modules/\n")
|
179
|
+
with mock.patch("gitghost.cli.Repo.init"), \
|
180
|
+
mock.patch("subprocess.run", side_effect=FileNotFoundError):
|
181
|
+
runner = CliRunner()
|
182
|
+
result = runner.invoke(cli, ["init"])
|
183
|
+
content = (tmp_path / ".gitignore").read_text()
|
184
|
+
assert ".gitghost_private" in content
|
185
|
+
|
186
|
+
def test_init_gitignore_exists_with_block(monkeypatch, tmp_path):
|
187
|
+
os.chdir(tmp_path)
|
188
|
+
(tmp_path / ".gitghostinclude").write_text("secret.txt\n")
|
189
|
+
(tmp_path / "secret.txt").write_text("top secret")
|
190
|
+
(tmp_path / ".gitignore").write_text("# GitGhost\n.gitghost_private/\n.gitghostinclude\n")
|
191
|
+
with mock.patch("gitghost.cli.Repo.init"), \
|
192
|
+
mock.patch("subprocess.run", side_effect=FileNotFoundError):
|
193
|
+
runner = CliRunner()
|
194
|
+
result = runner.invoke(cli, ["init"])
|
195
|
+
content = (tmp_path / ".gitignore").read_text()
|
196
|
+
assert content.count(".gitghost_private") == 1 # no duplicate block
|
197
|
+
|
198
|
+
def test_init_private_repo_fallback(monkeypatch):
|
199
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
200
|
+
monkeypatch.setattr("os.path.exists", lambda p: True)
|
201
|
+
repo_cls = mock.Mock(side_effect=Exception("fail"))
|
202
|
+
repo_init = mock.Mock()
|
203
|
+
with mock.patch("gitghost.cli.Repo", repo_cls), \
|
204
|
+
mock.patch("gitghost.cli.Repo.init", repo_init), \
|
205
|
+
mock.patch("gitghost.cli.open", mock.mock_open(), create=True), \
|
206
|
+
mock.patch("subprocess.run", side_effect=FileNotFoundError):
|
207
|
+
runner = CliRunner()
|
208
|
+
result = runner.invoke(cli, ["init"])
|
209
|
+
repo_init.assert_called()
|
210
|
+
def test_status_git_command_error_branches(monkeypatch):
|
211
|
+
repo_mock = mock.Mock()
|
212
|
+
repo_mock.head.is_valid.return_value = True
|
213
|
+
repo_mock.index.diff.side_effect = GitCommandError("diff", 1)
|
214
|
+
repo_mock.untracked_files = []
|
215
|
+
with mock.patch("gitghost.cli.ensure_private_repo", return_value=repo_mock), \
|
216
|
+
mock.patch("gitghost.cli.parse_gitghostinclude", return_value=["file1.txt"]), \
|
217
|
+
mock.patch("gitghost.cli.copy_private_files"), \
|
218
|
+
mock.patch("os.path.exists", return_value=True):
|
219
|
+
runner = CliRunner()
|
220
|
+
def test_init_nested_exceptions(monkeypatch):
|
221
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
222
|
+
monkeypatch.setattr("os.path.exists", lambda p: False)
|
223
|
+
repo_cls = mock.Mock(side_effect=Exception("fail"))
|
224
|
+
repo_init = mock.Mock()
|
225
|
+
def subprocess_run_side_effect(*args, **kwargs):
|
226
|
+
if "gh" in args[0]:
|
227
|
+
return mock.Mock(returncode=0)
|
228
|
+
return mock.DEFAULT
|
229
|
+
with mock.patch("gitghost.cli.Repo", repo_cls), \
|
230
|
+
mock.patch("gitghost.cli.Repo.init", repo_init), \
|
231
|
+
mock.patch("gitghost.cli.open", mock.mock_open(), create=True), \
|
232
|
+
mock.patch("subprocess.run", side_effect=subprocess_run_side_effect), \
|
233
|
+
mock.patch("subprocess.check_output", side_effect=Exception("fail")), \
|
234
|
+
mock.patch("gitghost.cli.Repo.create_remote", side_effect=Exception("fail")):
|
235
|
+
runner = CliRunner()
|
236
|
+
result = runner.invoke(cli, ["init"])
|
237
|
+
# Should handle nested exceptions gracefully
|
238
|
+
result = runner.invoke(cli, ["status"])
|
239
|
+
# Should handle error gracefully, no crash
|
240
|
+
def test_init_repo_exists_exception(monkeypatch):
|
241
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
242
|
+
monkeypatch.setattr("os.path.exists", lambda p: True)
|
243
|
+
repo_cls = mock.Mock(side_effect=Exception("fail"))
|
244
|
+
repo_init = mock.Mock()
|
245
|
+
with mock.patch("gitghost.cli.Repo", repo_cls), \
|
246
|
+
mock.patch("gitghost.cli.Repo.init", repo_init), \
|
247
|
+
mock.patch("gitghost.cli.open", mock.mock_open(), create=True), \
|
248
|
+
mock.patch("subprocess.run", side_effect=FileNotFoundError):
|
249
|
+
runner = CliRunner()
|
250
|
+
result = runner.invoke(cli, ["init"])
|
251
|
+
repo_init.assert_called()
|
252
|
+
|
253
|
+
def test_init_get_public_repo_name_exception(monkeypatch):
|
254
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
255
|
+
monkeypatch.setattr("os.path.exists", lambda p: False)
|
256
|
+
with mock.patch("gitghost.cli.Repo.init"), \
|
257
|
+
mock.patch("gitghost.cli.open", mock.mock_open(), create=True), \
|
258
|
+
mock.patch("subprocess.run") as subproc_mock, \
|
259
|
+
mock.patch("gitghost.cli.Repo") as repo_mock:
|
260
|
+
repo_mock.return_value.remote.return_value.urls = ["https://github.com/user/repo.git"]
|
261
|
+
repo_mock.side_effect = Exception("fail")
|
262
|
+
runner = CliRunner()
|
263
|
+
result = runner.invoke(cli, ["init"])
|
264
|
+
# Should fallback to directory name, no crash
|
265
|
+
|
266
|
+
def test_init_subprocess_repo_create_failure(monkeypatch):
|
267
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
268
|
+
monkeypatch.setattr("os.path.exists", lambda p: False)
|
269
|
+
def subprocess_run_side_effect(*args, **kwargs):
|
270
|
+
if "gh" in args[0]:
|
271
|
+
raise Exception("gh error")
|
272
|
+
return mock.DEFAULT
|
273
|
+
with mock.patch("gitghost.cli.Repo.init"), \
|
274
|
+
mock.patch("gitghost.cli.open", mock.mock_open(), create=True), \
|
275
|
+
mock.patch("subprocess.run", side_effect=subprocess_run_side_effect):
|
276
|
+
runner = CliRunner()
|
277
|
+
result = runner.invoke(cli, ["init"])
|
278
|
+
# Should handle error gracefully
|
279
|
+
|
280
|
+
def test_cli_entry_point():
|
281
|
+
# Cover line 300
|
282
|
+
from gitghost import cli as cli_module
|
283
|
+
with pytest.raises(SystemExit):
|
284
|
+
cli_module.cli()
|
@@ -0,0 +1,273 @@
|
|
1
|
+
import os
|
2
|
+
import pytest
|
3
|
+
from unittest import mock
|
4
|
+
from click.testing import CliRunner
|
5
|
+
from gitghost import cli
|
6
|
+
|
7
|
+
def test_parse_gitghostinclude_missing(monkeypatch):
|
8
|
+
monkeypatch.setattr("os.path.exists", lambda p: False)
|
9
|
+
with mock.patch("builtins.print"):
|
10
|
+
files = cli.parse_gitghostinclude()
|
11
|
+
assert files == []
|
12
|
+
|
13
|
+
def test_ensure_private_repo_init_when_missing(monkeypatch):
|
14
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
15
|
+
monkeypatch.setattr("os.path.exists", lambda p: False)
|
16
|
+
with mock.patch("os.makedirs") as makedirs_mock, \
|
17
|
+
mock.patch("gitghost.cli.Repo.init") as repo_init_mock:
|
18
|
+
repo_obj = mock.Mock()
|
19
|
+
repo_init_mock.return_value = repo_obj
|
20
|
+
repo = cli.ensure_private_repo()
|
21
|
+
makedirs_mock.assert_called()
|
22
|
+
repo_init_mock.assert_called()
|
23
|
+
assert repo == repo_obj
|
24
|
+
|
25
|
+
def test_ensure_private_repo_fallback_to_init(monkeypatch):
|
26
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
27
|
+
monkeypatch.setattr("os.path.exists", lambda p: True)
|
28
|
+
with mock.patch("gitghost.cli.Repo") as repo_cls, \
|
29
|
+
mock.patch("gitghost.cli.Repo.init") as repo_init_mock:
|
30
|
+
repo_cls.side_effect = Exception("fail")
|
31
|
+
repo_obj = mock.Mock()
|
32
|
+
repo_init_mock.return_value = repo_obj
|
33
|
+
repo = cli.ensure_private_repo()
|
34
|
+
repo_init_mock.assert_called()
|
35
|
+
assert repo == repo_obj
|
36
|
+
|
37
|
+
def test_copy_private_files_skips_missing(monkeypatch):
|
38
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
39
|
+
monkeypatch.setattr("os.path.exists", lambda p: False)
|
40
|
+
cli.copy_private_files(["secret.txt"]) # Should skip without error
|
41
|
+
|
42
|
+
def test_copy_private_files_directory(monkeypatch):
|
43
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
44
|
+
# Source exists and is directory
|
45
|
+
def exists_side_effect(path):
|
46
|
+
if "secret_dir" in path:
|
47
|
+
return True
|
48
|
+
return False
|
49
|
+
monkeypatch.setattr("os.path.exists", exists_side_effect)
|
50
|
+
monkeypatch.setattr("os.path.isdir", lambda p: True)
|
51
|
+
with mock.patch("shutil.rmtree") as rmtree_mock, \
|
52
|
+
mock.patch("shutil.copytree") as copytree_mock:
|
53
|
+
cli.copy_private_files(["secret_dir"])
|
54
|
+
rmtree_mock.assert_called()
|
55
|
+
copytree_mock.assert_called()
|
56
|
+
|
57
|
+
def test_save_push_error(monkeypatch):
|
58
|
+
repo_mock = mock.Mock()
|
59
|
+
repo_mock.head.is_valid.return_value = False
|
60
|
+
repo_mock.index.diff.return_value = []
|
61
|
+
repo_mock.untracked_files = []
|
62
|
+
repo_mock.index.add.return_value = None
|
63
|
+
repo_mock.is_dirty.return_value = True
|
64
|
+
repo_mock.remote.return_value.push.side_effect = cli.GitCommandError("push", 1)
|
65
|
+
repo_mock.remote.return_value.git.push.side_effect = None
|
66
|
+
repo_mock.remote.return_value.name = "origin"
|
67
|
+
repo_mock.remotes = []
|
68
|
+
repo_mock.active_branch.name = "main"
|
69
|
+
|
70
|
+
with mock.patch("gitghost.cli.ensure_private_repo", return_value=repo_mock), \
|
71
|
+
mock.patch("gitghost.cli.parse_gitghostinclude", return_value=["secret.txt"]), \
|
72
|
+
mock.patch("gitghost.cli.copy_private_files"), \
|
73
|
+
mock.patch("subprocess.run"), \
|
74
|
+
mock.patch("os.path.exists", return_value=True), \
|
75
|
+
mock.patch("os.getcwd", return_value="/tmp/testcwd"):
|
76
|
+
runner = CliRunner()
|
77
|
+
result = runner.invoke(cli.save, ["--message", "test commit"])
|
78
|
+
# Accept any exit code, just ensure no crash
|
79
|
+
assert result.exit_code in (0, 1)
|
80
|
+
|
81
|
+
def test_init_github_cli_missing(monkeypatch):
|
82
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
83
|
+
monkeypatch.setattr("os.path.exists", lambda p: False)
|
84
|
+
with mock.patch("gitghost.cli.Repo.init"), \
|
85
|
+
mock.patch("gitghost.cli.open", mock.mock_open(), create=True), \
|
86
|
+
mock.patch("subprocess.run", side_effect=FileNotFoundError):
|
87
|
+
runner = CliRunner()
|
88
|
+
result = runner.invoke(cli.init)
|
89
|
+
assert result.exit_code in (0, 1)
|
90
|
+
|
91
|
+
def test_parse_gitghostinclude_with_comments_and_empty(monkeypatch):
|
92
|
+
monkeypatch.setattr("os.path.exists", lambda p: True)
|
93
|
+
mock_file = mock.mock_open(read_data="# comment line\n\nfile1.txt\n \n#another\nfile2.txt\n")
|
94
|
+
with mock.patch("builtins.open", mock_file):
|
95
|
+
files = cli.parse_gitghostinclude()
|
96
|
+
assert files == ["file1.txt", "file2.txt"]
|
97
|
+
|
98
|
+
def test_status_git_error(monkeypatch):
|
99
|
+
repo_mock = mock.Mock()
|
100
|
+
repo_mock.head.is_valid.return_value = True
|
101
|
+
repo_mock.index.diff.side_effect = cli.GitCommandError("diff", 1)
|
102
|
+
|
103
|
+
with mock.patch("gitghost.cli.ensure_private_repo", return_value=repo_mock), \
|
104
|
+
mock.patch("gitghost.cli.parse_gitghostinclude", return_value=["secret.txt"]), \
|
105
|
+
mock.patch("gitghost.cli.copy_private_files"), \
|
106
|
+
mock.patch("os.path.exists", return_value=True), \
|
107
|
+
mock.patch("os.path.isdir", return_value=False):
|
108
|
+
runner = CliRunner()
|
109
|
+
result = runner.invoke(cli.status)
|
110
|
+
assert result.exit_code == 0 # Should handle error gracefully
|
111
|
+
|
112
|
+
def test_init_repo_name_fallback(monkeypatch):
|
113
|
+
"""Test init command falls back to directory name when git repo check fails"""
|
114
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
115
|
+
monkeypatch.setattr("os.path.exists", lambda p: False)
|
116
|
+
monkeypatch.setattr("os.path.basename", lambda p: "fallback-name")
|
117
|
+
|
118
|
+
# Mock subprocess.run to simulate gh command available
|
119
|
+
def run_side_effect(*args, **kwargs):
|
120
|
+
if args[0][0] == "gh" and args[0][1] == "--version":
|
121
|
+
return mock.Mock(returncode=0)
|
122
|
+
elif args[0][0] == "gh" and args[0][1] == "repo":
|
123
|
+
return mock.Mock(returncode=1) # Repo doesn't exist
|
124
|
+
return mock.Mock(returncode=0)
|
125
|
+
|
126
|
+
with mock.patch("gitghost.cli.Repo", side_effect=Exception("repo error")), \
|
127
|
+
mock.patch("gitghost.cli.open", mock.mock_open(), create=True), \
|
128
|
+
mock.patch("subprocess.run", side_effect=run_side_effect) as subprocess_run:
|
129
|
+
runner = CliRunner()
|
130
|
+
result = runner.invoke(cli.init)
|
131
|
+
assert result.exit_code == 0
|
132
|
+
# Check if gh create was called with fallback name
|
133
|
+
create_calls = [c for c in subprocess_run.call_args_list if c[0][0][1] == "repo" and c[0][0][2] == "create"]
|
134
|
+
assert any("fallback-name-gitghost" in str(call) for call in create_calls)
|
135
|
+
|
136
|
+
def test_init_gitignore_updating(monkeypatch):
|
137
|
+
"""Test .gitignore updating with various content states"""
|
138
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
139
|
+
test_cases = [
|
140
|
+
# Case 1: No GitGhost entries
|
141
|
+
"some content\n",
|
142
|
+
# Case 2: Has .gitghost_private/ but not .gitghostinclude
|
143
|
+
"some content\n.gitghost_private/\n",
|
144
|
+
# Case 3: Has .gitghostinclude but not .gitghost_private/
|
145
|
+
"some content\n.gitghostinclude\n",
|
146
|
+
# Case 4: Already has both entries
|
147
|
+
"some content\n.gitghost_private/\n.gitghostinclude\n"
|
148
|
+
]
|
149
|
+
|
150
|
+
for content in test_cases:
|
151
|
+
with mock.patch("gitghost.cli.open", mock.mock_open(read_data=content), create=True) as mock_file, \
|
152
|
+
mock.patch("os.path.exists", return_value=True), \
|
153
|
+
mock.patch("gitghost.cli.Repo.init"):
|
154
|
+
runner = CliRunner()
|
155
|
+
result = runner.invoke(cli.init)
|
156
|
+
assert result.exit_code == 0
|
157
|
+
|
158
|
+
# Check write calls when content needed updating
|
159
|
+
if ".gitghost_private/" not in content or ".gitghostinclude" not in content:
|
160
|
+
mock_file().write.assert_called()
|
161
|
+
else:
|
162
|
+
# Both entries present, no update needed
|
163
|
+
assert not mock_file().write.called or mock_file().write.call_count == 0
|
164
|
+
|
165
|
+
def test_init_gh_create_error(monkeypatch):
|
166
|
+
"""Test init command when GitHub CLI repo creation fails"""
|
167
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
168
|
+
monkeypatch.setattr("os.path.exists", lambda p: False)
|
169
|
+
|
170
|
+
def subprocess_run_mock(*args, **kwargs):
|
171
|
+
if args[0][0] == "gh" and args[0][1] == "--version":
|
172
|
+
return mock.Mock(returncode=0) # gh CLI is available
|
173
|
+
if args[0][0] == "gh" and args[0][1] == "repo" and args[0][2] == "create":
|
174
|
+
raise Exception("gh create error")
|
175
|
+
return mock.Mock(returncode=1)
|
176
|
+
|
177
|
+
with mock.patch("gitghost.cli.Repo.init"), \
|
178
|
+
mock.patch("gitghost.cli.open", mock.mock_open(), create=True), \
|
179
|
+
mock.patch("subprocess.run", side_effect=subprocess_run_mock):
|
180
|
+
runner = CliRunner()
|
181
|
+
result = runner.invoke(cli.init)
|
182
|
+
assert result.exit_code == 0
|
183
|
+
assert "Failed to create private GitHub repo" in result.output
|
184
|
+
|
185
|
+
def test_copy_private_files_file_copy_error(monkeypatch):
|
186
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
187
|
+
def exists_side_effect(path):
|
188
|
+
if "secret_file.txt" in path:
|
189
|
+
return True
|
190
|
+
return False
|
191
|
+
monkeypatch.setattr("os.path.exists", exists_side_effect)
|
192
|
+
monkeypatch.setattr("os.path.isdir", lambda p: False)
|
193
|
+
with mock.patch("os.path.dirname", return_value="/tmp/testcwd/.gitghost_private"), \
|
194
|
+
mock.patch("os.makedirs"), \
|
195
|
+
mock.patch("shutil.copy2", side_effect=Exception("copy error")):
|
196
|
+
with pytest.raises(Exception):
|
197
|
+
cli.copy_private_files(["secret_file.txt"])
|
198
|
+
def test_copy_private_files_file_copy(monkeypatch):
|
199
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
200
|
+
# Source exists and is a file
|
201
|
+
def exists_side_effect(path):
|
202
|
+
if "secret_file.txt" in path:
|
203
|
+
return True
|
204
|
+
return False
|
205
|
+
monkeypatch.setattr("os.path.exists", exists_side_effect)
|
206
|
+
monkeypatch.setattr("os.path.isdir", lambda p: False)
|
207
|
+
with mock.patch("os.path.dirname", return_value="/tmp/testcwd/.gitghost_private"), \
|
208
|
+
mock.patch("os.makedirs") as makedirs_mock, \
|
209
|
+
mock.patch("shutil.copy2") as copy2_mock:
|
210
|
+
cli.copy_private_files(["secret_file.txt"])
|
211
|
+
makedirs_mock.assert_called()
|
212
|
+
copy2_mock.assert_called()
|
213
|
+
|
214
|
+
def test_discard_cancel(monkeypatch):
|
215
|
+
"""Test discard command when user cancels"""
|
216
|
+
with mock.patch("builtins.input", return_value="n"):
|
217
|
+
runner = CliRunner()
|
218
|
+
result = runner.invoke(cli.discard)
|
219
|
+
assert result.exit_code == 0
|
220
|
+
assert "Discard cancelled" in result.output
|
221
|
+
|
222
|
+
def test_discard_confirmed_with_missing_files(monkeypatch):
|
223
|
+
"""Test discard command with confirmed overwrite and some missing vault files"""
|
224
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
225
|
+
monkeypatch.setattr("os.path.exists", lambda p: False) # Files missing in vault
|
226
|
+
|
227
|
+
with mock.patch("builtins.input", return_value="y"), \
|
228
|
+
mock.patch("gitghost.cli.parse_gitghostinclude", return_value=["file1.txt", "dir1"]), \
|
229
|
+
mock.patch("subprocess.run"):
|
230
|
+
runner = CliRunner()
|
231
|
+
result = runner.invoke(cli.discard)
|
232
|
+
assert result.exit_code == 0
|
233
|
+
assert "Skipping missing in vault: file1.txt" in result.output
|
234
|
+
assert "Skipping missing in vault: dir1" in result.output
|
235
|
+
|
236
|
+
def test_discard_confirmed_with_files(monkeypatch):
|
237
|
+
"""Test discard command with confirmed overwrite and existing files"""
|
238
|
+
monkeypatch.setattr("os.getcwd", lambda: "/tmp/testcwd")
|
239
|
+
|
240
|
+
def exists_side_effect(path):
|
241
|
+
# Files in vault that we want to restore
|
242
|
+
vault_paths = [
|
243
|
+
"/tmp/testcwd/.gitghost_private/file1.txt",
|
244
|
+
"/tmp/testcwd/.gitghost_private/dir1"
|
245
|
+
]
|
246
|
+
# Existing target that needs to be removed
|
247
|
+
target_paths = ["/tmp/testcwd/dir1"]
|
248
|
+
return path in vault_paths + target_paths
|
249
|
+
|
250
|
+
monkeypatch.setattr("os.path.exists", exists_side_effect)
|
251
|
+
|
252
|
+
with mock.patch("builtins.input", return_value="yes"), \
|
253
|
+
mock.patch("gitghost.cli.parse_gitghostinclude", return_value=["file1.txt", "dir1"]), \
|
254
|
+
mock.patch("subprocess.run"), \
|
255
|
+
mock.patch("os.path.isdir") as isdir_mock, \
|
256
|
+
mock.patch("os.makedirs") as makedirs_mock, \
|
257
|
+
mock.patch("shutil.copy2") as copy2_mock, \
|
258
|
+
mock.patch("shutil.rmtree") as rmtree_mock, \
|
259
|
+
mock.patch("shutil.copytree") as copytree_mock:
|
260
|
+
|
261
|
+
def isdir_side_effect(path):
|
262
|
+
return "dir1" in path
|
263
|
+
isdir_mock.side_effect = isdir_side_effect
|
264
|
+
|
265
|
+
runner = CliRunner()
|
266
|
+
result = runner.invoke(cli.discard)
|
267
|
+
assert result.exit_code == 0
|
268
|
+
|
269
|
+
# Verify file operations
|
270
|
+
rmtree_mock.assert_called_once_with("/tmp/testcwd/dir1")
|
271
|
+
copytree_mock.assert_called_once()
|
272
|
+
makedirs_mock.assert_called()
|
273
|
+
copy2_mock.assert_called_once()
|