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.
@@ -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,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,2 @@
1
+ [console_scripts]
2
+ gitghost = gitghost.cli:cli
@@ -0,0 +1,2 @@
1
+ click<9.0,>=8.0
2
+ gitpython<4.0,>=3.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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()