ghostnotes 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tuna Akin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghostnotes
3
+ Version: 1.0.0
4
+ Summary: Strip personal annotations from code before committing
5
+ Author-email: Tuna Akin <tuna.takin@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ # GhostNotes
13
+ **Version:** 1.0.0 | **Last Updated:** April 12, 2026
14
+
15
+ A Git tool that strips tagged comments before committing.
16
+
17
+ ---
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install ghostnotes
23
+ ```
24
+
25
+ Then initialize in any git repo:
26
+
27
+ ```bash
28
+ cd your-project
29
+ ghostnotes init
30
+ ```
31
+
32
+ ---
33
+
34
+ ## What it does
35
+ GhostNotes lets you leave personal notes in your code using a tag (default: `GN:`). Before every commit, it automatically strips them out so they never reach your repository.
36
+ ```python
37
+ x = some_function() # GN: this breaks when input is negative, fix later
38
+ ```
39
+ This comment line gets stripped before the commit so it looks like:
40
+ ```python
41
+ x = some_function()
42
+ ```
43
+
44
+ Your local file stays untouched.
45
+
46
+ ---
47
+
48
+ ## Commands
49
+
50
+ | Command | Description |
51
+ |---|---|
52
+ | `ghostnotes init` | Initialize GhostNotes in the current project |
53
+ | `ghostnotes status` | Show all GhostNotes in the current project |
54
+ | `ghostnotes set-tag --tag <tag>` | Change the default comment tag |
55
+ | `ghostnotes add-lang --ext <.ext> --symb <symbol>` | Add support for a new language |
56
+ | `ghostnotes pull` | Git pull that safely strips and re-applies your ghostnotes |
@@ -0,0 +1,45 @@
1
+ # GhostNotes
2
+ **Version:** 1.0.0 | **Last Updated:** April 12, 2026
3
+
4
+ A Git tool that strips tagged comments before committing.
5
+
6
+ ---
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install ghostnotes
12
+ ```
13
+
14
+ Then initialize in any git repo:
15
+
16
+ ```bash
17
+ cd your-project
18
+ ghostnotes init
19
+ ```
20
+
21
+ ---
22
+
23
+ ## What it does
24
+ GhostNotes lets you leave personal notes in your code using a tag (default: `GN:`). Before every commit, it automatically strips them out so they never reach your repository.
25
+ ```python
26
+ x = some_function() # GN: this breaks when input is negative, fix later
27
+ ```
28
+ This comment line gets stripped before the commit so it looks like:
29
+ ```python
30
+ x = some_function()
31
+ ```
32
+
33
+ Your local file stays untouched.
34
+
35
+ ---
36
+
37
+ ## Commands
38
+
39
+ | Command | Description |
40
+ |---|---|
41
+ | `ghostnotes init` | Initialize GhostNotes in the current project |
42
+ | `ghostnotes status` | Show all GhostNotes in the current project |
43
+ | `ghostnotes set-tag --tag <tag>` | Change the default comment tag |
44
+ | `ghostnotes add-lang --ext <.ext> --symb <symbol>` | Add support for a new language |
45
+ | `ghostnotes pull` | Git pull that safely strips and re-applies your ghostnotes |
File without changes
@@ -0,0 +1,54 @@
1
+ # CLI setup
2
+ import argparse
3
+ from ghostnotes.hook import install_hook, strip_ghostnotes
4
+ from ghostnotes.config import create_config, update_exclude, set_tag, add_lang_support
5
+ from ghostnotes.sync import pull, extract_notes
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser(description="GhostNotes")
9
+ subparsers = parser.add_subparsers(dest="command")
10
+
11
+ # init
12
+ init_parser = subparsers.add_parser("init", help="Initialize GhostNotes")
13
+
14
+ # set tag
15
+ tag_parser = subparsers.add_parser("set-tag", help="Set the tag for GhostNotes to ignore, Default tag is 'GN:'. ")
16
+ tag_parser.add_argument("--tag", required=True, help="New tag")
17
+
18
+ # add lang
19
+ lang_parser = subparsers.add_parser("add-lang", help="Add a new language support, Check .ghostnotes for the default languages supported")
20
+ lang_parser.add_argument("--ext", required=True, help="File extension (.py, .js, .sql etc.)")
21
+ lang_parser.add_argument("--symb", required=True, help="Symbol for commenting in the said language (# for .py, // for .java etc.)")
22
+
23
+ # pull
24
+ pull_parser = subparsers.add_parser("pull", help="Git pull with ghostnote-safe stripping and re-applying")
25
+
26
+ # status
27
+ status_parser = subparsers.add_parser("status", help="Show all ghost notes in the project")
28
+
29
+ args = parser.parse_args()
30
+
31
+ if args.command == "init":
32
+ create_config()
33
+ update_exclude()
34
+ install_hook()
35
+ print("GhostNotes is successfully initialized.")
36
+ elif args.command == "set-tag":
37
+ set_tag(args.tag)
38
+ elif args.command == "add-lang":
39
+ add_lang_support(args.ext, args.symb)
40
+ elif args.command == "pull":
41
+ pull()
42
+ elif args.command == "status":
43
+ notes = extract_notes()
44
+ if not notes:
45
+ print("No ghost notes found.")
46
+ else:
47
+ total = sum(len(v) for v in notes.values())
48
+ print(f"Found {total} ghost note(s):\n")
49
+ for file, file_notes in notes.items():
50
+ for n in file_notes:
51
+ print(f" {file}:{n['line_number'] + 1} — {n['note']}")
52
+
53
+ if __name__ == "__main__":
54
+ main()
@@ -0,0 +1,74 @@
1
+ # file for creating the .ghostnotes configuration
2
+ import configparser
3
+ import os
4
+
5
+ def create_config():
6
+ if not os.path.isdir('.git'):
7
+ print("This project is not initialized with git. GhostNotes only works for directories already initialized with git.")
8
+ return
9
+
10
+ else:
11
+ # set-up the default config
12
+ config = configparser.ConfigParser()
13
+ config['settings'] = {'tag': 'GN:'}
14
+ config['languages'] = {'.py': '#', '.java': '//', '.js': '//', '.ts': '//'}
15
+
16
+ with open('.ghostnotes', 'w') as configfile:
17
+ config.write(configfile)
18
+
19
+ print("GhostNotes: Config file is created succesfully")
20
+
21
+ def update_exclude():
22
+ loc = ".git/info/exclude"
23
+
24
+ with open(loc, 'r') as file:
25
+ for line in file:
26
+ # already in exclude
27
+ if line.strip() == '.ghostnotes':
28
+ return
29
+
30
+ file = open(loc, "a")
31
+ file.write('.ghostnotes\n')
32
+ file.close()
33
+
34
+ def load_config():
35
+ # check if config exists
36
+ if not os.path.isfile('.ghostnotes'):
37
+ print("This project is not initialized with GhostNotes.")
38
+ return
39
+
40
+ else:
41
+ # set-up the default config
42
+ config = configparser.ConfigParser()
43
+ config.read('.ghostnotes')
44
+ print("GhostNotes: Config file is read succesfully")
45
+
46
+ return config
47
+
48
+ # update tag
49
+ def set_tag(tag):
50
+ if not os.path.isfile('.ghostnotes'):
51
+ print("This project is not initialized with GhostNotes, cannot set new tag.")
52
+ return
53
+
54
+ config = configparser.ConfigParser()
55
+ config.read('.ghostnotes') # preserve existing settings
56
+ config['settings']['tag'] = tag
57
+ with open('.ghostnotes', 'w') as configfile:
58
+ config.write(configfile)
59
+
60
+ print(f"GhostNotes: Config file tag is set to {tag} succesfully")
61
+
62
+ # add custom language comment support
63
+ def add_lang_support(extension, notation):
64
+ if not os.path.isfile('.ghostnotes'):
65
+ print("This project is not initialized with GhostNotes, cannot add new file support.")
66
+ return
67
+
68
+ config = configparser.ConfigParser()
69
+ config.read('.ghostnotes')
70
+ config['languages'][extension] = notation
71
+ with open('.ghostnotes', 'w') as configfile:
72
+ config.write(configfile)
73
+
74
+ print(f"GhostNotes: Config file language support for {extension} files are added with symbol {notation} successfuly.")
@@ -0,0 +1,103 @@
1
+ import os
2
+ import stat
3
+ import subprocess
4
+ from pathlib import Path
5
+ from ghostnotes.config import load_config
6
+
7
+ # for adding this script to pre-commit
8
+ def install_hook():
9
+ path = '.git/hooks/pre-commit'
10
+ command = 'python3 -m ghostnotes.hook'
11
+
12
+ # if a pre-commit hook file already exists:
13
+ if os.path.exists(path):
14
+ with open(path, 'r') as file:
15
+ for line in file:
16
+ if line.strip() == command:
17
+ return
18
+
19
+ with open(path, 'a') as file:
20
+ file.write(f'\n{command}\n')
21
+ else:
22
+ with open(path, 'w') as file:
23
+ file.write(f'#!/bin/bash\n{command}\n')
24
+
25
+ # make the file executable
26
+ os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC)
27
+
28
+ # main logic function for stripping the committed files of the keyworded comments
29
+ def strip_ghostnotes():
30
+ """
31
+ Read Config
32
+ Get the list of staged files — how do you ask git which files are staged? Think about what terminal command you'd run manually.
33
+ Filter to only supported extensions
34
+ For each supported file, strip lines matching the tag.
35
+ Re-stage the files with git add
36
+ """
37
+ # read config
38
+ configuration = load_config()
39
+
40
+ if configuration is None:
41
+ print("Ghostnotes is not configured.")
42
+ return
43
+
44
+ # get the list of staged file:
45
+ res = subprocess.run(['git', 'diff', '--name-only', '--cached'], capture_output=True, text=True)
46
+ staged_files = res.stdout.splitlines()
47
+ supported_staged_files = list()
48
+
49
+ # filter staged_files to only supported file types
50
+ for f in staged_files:
51
+ if Path(f).suffix in configuration['languages']:
52
+ supported_staged_files.append(f)
53
+
54
+ # strip lines matching the tag
55
+ for file in supported_staged_files:
56
+ # check the commenting style for the file type from configuration['languages'] as well as the tag from configuration['settings'] to build the exact sequence of chars we are looking for
57
+ comment = configuration['languages'][Path(file).suffix]
58
+ tag = configuration['settings']['tag']
59
+ pattern = comment + ' ' + tag
60
+
61
+ # read the staged version from the git index, not the working tree
62
+ staged_content = subprocess.run(
63
+ ['git', 'show', f':{file}'],
64
+ capture_output=True, text=True
65
+ ).stdout
66
+
67
+ new_lines = list()
68
+ for line in staged_content.splitlines(keepends=True):
69
+ # strip only if the pattern is not inside a string literal
70
+ if pattern in line:
71
+ idx = line.index(pattern)
72
+ prefix = line[:idx]
73
+ # if an odd number of quotes precede the pattern, it's inside a string
74
+ in_string = (prefix.count('"') % 2 == 1) or (prefix.count("'") % 2 == 1)
75
+ if not in_string:
76
+ line = line[:idx].rstrip() + '\n'
77
+ new_lines.append(line)
78
+
79
+ stripped_content = ''.join(new_lines)
80
+
81
+ # write the stripped content back into the git index only
82
+ hash_result = subprocess.run(
83
+ ['git', 'hash-object', '-w', '--stdin'],
84
+ input=stripped_content, capture_output=True, text=True
85
+ )
86
+ blob_hash = hash_result.stdout.strip()
87
+
88
+ # get the file's mode from the index
89
+ ls_result = subprocess.run(
90
+ ['git', 'ls-files', '--stage', file],
91
+ capture_output=True, text=True
92
+ )
93
+ if not ls_result.stdout.strip():
94
+ continue
95
+ mode = ls_result.stdout.split()[0]
96
+
97
+ # update the index entry with the new blob
98
+ subprocess.run(
99
+ ['git', 'update-index', '--cacheinfo', f'{mode},{blob_hash},{file}']
100
+ )
101
+
102
+ if __name__ == "__main__":
103
+ strip_ghostnotes()
@@ -0,0 +1,140 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from ghostnotes.config import load_config
4
+
5
+
6
+ def extract_notes():
7
+ config = load_config()
8
+ notes = {}
9
+
10
+ for file in Path('.').rglob('*'):
11
+ # skip hidden directories (.git, .venv, etc.)
12
+ if any(part.startswith('.') for part in file.parts):
13
+ continue
14
+
15
+ if not file.is_file():
16
+ continue
17
+
18
+ ext = file.suffix
19
+ if ext not in config['languages']:
20
+ continue
21
+
22
+ comment = config['languages'][ext]
23
+ tag = config['settings']['tag']
24
+ pattern = comment + ' ' + tag
25
+
26
+ # extract notes, store code without comment, store comment, and store line num
27
+ file_notes = []
28
+ try:
29
+ with open(file, 'r') as f:
30
+ for i, line in enumerate(f):
31
+ if pattern in line:
32
+ stripped = line.split(pattern)[0].rstrip()
33
+ note_text = line.split(pattern, 1)[1].strip()
34
+ file_notes.append({
35
+ 'stripped_line': stripped,
36
+ 'note': note_text,
37
+ 'line_number': i,
38
+ })
39
+ except UnicodeDecodeError:
40
+ continue
41
+
42
+ if file_notes:
43
+ notes[str(file)] = file_notes
44
+
45
+ return notes
46
+
47
+ def strip_working_tree(notes):
48
+ for file, file_notes in notes.items():
49
+ patterns = {n['line_number'] for n in file_notes}
50
+
51
+ with open(file, 'r') as f:
52
+ lines = f.readlines()
53
+
54
+ for line_num in patterns:
55
+ note_entry = next(n for n in file_notes if n['line_number'] == line_num)
56
+ lines[line_num] = note_entry['stripped_line'] + '\n'
57
+
58
+ with open(file, 'w') as f:
59
+ f.writelines(lines)
60
+
61
+
62
+ def reapply_notes(notes, config):
63
+ for file, file_notes in notes.items():
64
+ if not Path(file).is_file():
65
+ for n in file_notes:
66
+ print(f" ORPHANED (file deleted): {file} — {n['note']}")
67
+ continue
68
+
69
+ comment = config['languages'][Path(file).suffix]
70
+ tag = config['settings']['tag']
71
+ pattern = comment + ' ' + tag
72
+
73
+ with open(file, 'r') as f:
74
+ lines = f.readlines()
75
+
76
+ orphaned = []
77
+
78
+ for n in file_notes:
79
+ target = n['stripped_line']
80
+ line_num = n['line_number']
81
+ matched = False
82
+
83
+ # 1. exact match at same line number
84
+ if line_num < len(lines) and lines[line_num].rstrip() == target:
85
+ lines[line_num] = target + ' ' + pattern + ' ' + n['note'] + '\n'
86
+ matched = True
87
+
88
+ # 2. exact match nearby (within 20 lines)
89
+ if not matched:
90
+ start = max(0, line_num - 20)
91
+ end = min(len(lines), line_num + 20)
92
+ for i in range(start, end):
93
+ if lines[i].rstrip() == target:
94
+ lines[i] = target + ' ' + pattern + ' ' + n['note'] + '\n'
95
+ matched = True
96
+ break
97
+
98
+ # 3. exact match anywhere in file
99
+ if not matched:
100
+ for i in range(len(lines)):
101
+ if lines[i].rstrip() == target:
102
+ lines[i] = target + ' ' + pattern + ' ' + n['note'] + '\n'
103
+ matched = True
104
+ break
105
+
106
+ # 4. orphaned
107
+ if not matched:
108
+ orphaned.append(n)
109
+
110
+ with open(file, 'w') as f:
111
+ f.writelines(lines)
112
+
113
+ for n in orphaned:
114
+ print(f" ORPHANED: {file}:{n['line_number']} — {n['note']}")
115
+
116
+
117
+ def pull():
118
+ config = load_config()
119
+ notes = extract_notes()
120
+
121
+ if notes:
122
+ print(f"GhostNotes: Saved {sum(len(v) for v in notes.values())} note(s), stripping before pull...")
123
+ strip_working_tree(notes)
124
+
125
+ result = subprocess.run(['git', 'pull'], capture_output=True, text=True)
126
+ print(result.stdout)
127
+ if result.stderr:
128
+ print(result.stderr)
129
+
130
+ if notes:
131
+ if result.returncode != 0:
132
+ print("GhostNotes: Pull failed, restoring notes to working tree...")
133
+ reapply_notes(notes, config)
134
+ print("GhostNotes: Notes restored. Resolve the pull issue and try again.")
135
+ else:
136
+ print("GhostNotes: Re-applying notes...")
137
+ reapply_notes(notes, config)
138
+ print("GhostNotes: Done.")
139
+
140
+ return result.returncode
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghostnotes
3
+ Version: 1.0.0
4
+ Summary: Strip personal annotations from code before committing
5
+ Author-email: Tuna Akin <tuna.takin@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Dynamic: license-file
11
+
12
+ # GhostNotes
13
+ **Version:** 1.0.0 | **Last Updated:** April 12, 2026
14
+
15
+ A Git tool that strips tagged comments before committing.
16
+
17
+ ---
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ pip install ghostnotes
23
+ ```
24
+
25
+ Then initialize in any git repo:
26
+
27
+ ```bash
28
+ cd your-project
29
+ ghostnotes init
30
+ ```
31
+
32
+ ---
33
+
34
+ ## What it does
35
+ GhostNotes lets you leave personal notes in your code using a tag (default: `GN:`). Before every commit, it automatically strips them out so they never reach your repository.
36
+ ```python
37
+ x = some_function() # GN: this breaks when input is negative, fix later
38
+ ```
39
+ This comment line gets stripped before the commit so it looks like:
40
+ ```python
41
+ x = some_function()
42
+ ```
43
+
44
+ Your local file stays untouched.
45
+
46
+ ---
47
+
48
+ ## Commands
49
+
50
+ | Command | Description |
51
+ |---|---|
52
+ | `ghostnotes init` | Initialize GhostNotes in the current project |
53
+ | `ghostnotes status` | Show all GhostNotes in the current project |
54
+ | `ghostnotes set-tag --tag <tag>` | Change the default comment tag |
55
+ | `ghostnotes add-lang --ext <.ext> --symb <symbol>` | Add support for a new language |
56
+ | `ghostnotes pull` | Git pull that safely strips and re-applies your ghostnotes |
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ ghostnotes/__init__.py
5
+ ghostnotes/cli.py
6
+ ghostnotes/config.py
7
+ ghostnotes/hook.py
8
+ ghostnotes/sync.py
9
+ ghostnotes.egg-info/PKG-INFO
10
+ ghostnotes.egg-info/SOURCES.txt
11
+ ghostnotes.egg-info/dependency_links.txt
12
+ ghostnotes.egg-info/entry_points.txt
13
+ ghostnotes.egg-info/top_level.txt
14
+ tests/test_config.py
15
+ tests/test_hook.py
16
+ tests/test_sync.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ghostnotes = ghostnotes.cli:main
@@ -0,0 +1 @@
1
+ ghostnotes
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ghostnotes"
7
+ version = "1.0.0"
8
+ description = "Strip personal annotations from code before committing"
9
+ requires-python = ">=3.8"
10
+ license = "MIT"
11
+ authors = [
12
+ {name = "Tuna Akin", email = "tuna.takin@gmail.com"}
13
+ ]
14
+ readme = "README.md"
15
+
16
+ [project.scripts]
17
+ ghostnotes = "ghostnotes.cli:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,222 @@
1
+ import os
2
+ import tempfile
3
+ import configparser
4
+
5
+ from ghostnotes.config import (
6
+ create_config,
7
+ load_config,
8
+ set_tag,
9
+ add_lang_support,
10
+ update_exclude,
11
+ )
12
+
13
+
14
+ # --- Helper ---
15
+ # set up a fake project folder so we don't touch real files.
16
+ def make_fake_git_dir():
17
+ tmp = tempfile.mkdtemp()
18
+ os.makedirs(os.path.join(tmp, ".git", "info"))
19
+ with open(os.path.join(tmp, ".git", "info", "exclude"), "w") as f:
20
+ f.write("")
21
+ return tmp
22
+
23
+
24
+ def test_create_config_makes_file():
25
+ tmp = make_fake_git_dir()
26
+ original_dir = os.getcwd()
27
+ os.chdir(tmp)
28
+
29
+ create_config()
30
+
31
+ # check: the file should now exist
32
+ assert os.path.isfile(".ghostnotes")
33
+
34
+ # go back and remove temp dir
35
+ os.chdir(original_dir)
36
+
37
+
38
+ def test_create_config_has_default_tag():
39
+ tmp = make_fake_git_dir()
40
+ original_dir = os.getcwd()
41
+ os.chdir(tmp)
42
+
43
+ create_config()
44
+
45
+ # read the file and check the tag value
46
+ config = configparser.ConfigParser()
47
+ config.read(".ghostnotes")
48
+ assert config["settings"]["tag"] == "GN:"
49
+
50
+ os.chdir(original_dir)
51
+
52
+
53
+ def test_create_config_has_default_languages():
54
+ tmp = make_fake_git_dir()
55
+ original_dir = os.getcwd()
56
+ os.chdir(tmp)
57
+
58
+ create_config()
59
+
60
+ config = configparser.ConfigParser()
61
+ config.read(".ghostnotes")
62
+ assert config["languages"][".py"] == "#"
63
+ assert config["languages"][".java"] == "//"
64
+ assert config["languages"][".js"] == "//"
65
+ assert config["languages"][".ts"] == "//"
66
+
67
+ os.chdir(original_dir)
68
+
69
+
70
+ def test_create_config_refuses_without_git():
71
+ tmp = tempfile.mkdtemp()
72
+ original_dir = os.getcwd()
73
+ os.chdir(tmp)
74
+
75
+ create_config()
76
+
77
+ # .ghostnotes should not exist because there's no git
78
+ assert not os.path.isfile(".ghostnotes")
79
+
80
+ os.chdir(original_dir)
81
+
82
+ def test_load_config_returns_config():
83
+ tmp = make_fake_git_dir()
84
+ original_dir = os.getcwd()
85
+ os.chdir(tmp)
86
+
87
+ create_config()
88
+ config = load_config()
89
+ assert config is not None
90
+ assert config["settings"]["tag"] == "GN:"
91
+
92
+ os.chdir(original_dir)
93
+
94
+
95
+ def test_load_config_returns_none_without_file():
96
+ tmp = tempfile.mkdtemp()
97
+ original_dir = os.getcwd()
98
+ os.chdir(tmp)
99
+
100
+ result = load_config()
101
+
102
+ assert result is None
103
+
104
+ os.chdir(original_dir)
105
+
106
+ def test_set_tag_changes_tag():
107
+ tmp = make_fake_git_dir()
108
+ original_dir = os.getcwd()
109
+ os.chdir(tmp)
110
+
111
+ create_config()
112
+ set_tag("TODO:")
113
+
114
+ # read the file again and check the tag changed
115
+ config = configparser.ConfigParser()
116
+ config.read(".ghostnotes")
117
+ assert config["settings"]["tag"] == "TODO:"
118
+
119
+ os.chdir(original_dir)
120
+
121
+
122
+ def test_set_tag_keeps_languages():
123
+ tmp = make_fake_git_dir()
124
+ original_dir = os.getcwd()
125
+ os.chdir(tmp)
126
+
127
+ create_config()
128
+ set_tag("NOTE:")
129
+
130
+ config = configparser.ConfigParser()
131
+ config.read(".ghostnotes")
132
+
133
+ assert ".py" in config["languages"]
134
+ assert ".js" in config["languages"]
135
+
136
+ os.chdir(original_dir)
137
+
138
+
139
+ def test_set_tag_refuses_without_config():
140
+ tmp = tempfile.mkdtemp()
141
+ original_dir = os.getcwd()
142
+ os.chdir(tmp)
143
+
144
+ set_tag("NEW:")
145
+
146
+ # .ghostnotes should still not exist
147
+ assert not os.path.isfile(".ghostnotes")
148
+
149
+ os.chdir(original_dir)
150
+
151
+
152
+ def test_add_lang_support_adds_language():
153
+ tmp = make_fake_git_dir()
154
+ original_dir = os.getcwd()
155
+ os.chdir(tmp)
156
+
157
+ create_config()
158
+ add_lang_support(".rb", "#")
159
+
160
+ config = configparser.ConfigParser()
161
+ config.read(".ghostnotes")
162
+ assert config["languages"][".rb"] == "#"
163
+
164
+ os.chdir(original_dir)
165
+
166
+
167
+ def test_add_lang_support_keeps_existing():
168
+ tmp = make_fake_git_dir()
169
+ original_dir = os.getcwd()
170
+ os.chdir(tmp)
171
+
172
+ create_config()
173
+ add_lang_support(".go", "//")
174
+
175
+ config = configparser.ConfigParser()
176
+ config.read(".ghostnotes")
177
+ assert config["languages"][".py"] == "#"
178
+ assert config["languages"][".go"] == "//"
179
+
180
+ os.chdir(original_dir)
181
+
182
+
183
+ def test_add_lang_support_refuses_without_config():
184
+ tmp = tempfile.mkdtemp()
185
+ original_dir = os.getcwd()
186
+ os.chdir(tmp)
187
+
188
+ add_lang_support(".rb", "#")
189
+
190
+ assert not os.path.isfile(".ghostnotes")
191
+
192
+ os.chdir(original_dir)
193
+
194
+ def test_update_exclude_adds_entry():
195
+ tmp = make_fake_git_dir()
196
+ original_dir = os.getcwd()
197
+ os.chdir(tmp)
198
+
199
+ update_exclude()
200
+
201
+ with open(".git/info/exclude", "r") as f:
202
+ contents = f.read()
203
+ assert ".ghostnotes" in contents
204
+
205
+ os.chdir(original_dir)
206
+
207
+
208
+ def test_update_exclude_no_duplicates():
209
+ tmp = make_fake_git_dir()
210
+ original_dir = os.getcwd()
211
+ os.chdir(tmp)
212
+
213
+ update_exclude()
214
+ update_exclude()
215
+
216
+ with open(".git/info/exclude", "r") as f:
217
+ lines = f.readlines()
218
+
219
+ count = sum(1 for line in lines if line.strip() == ".ghostnotes")
220
+ assert count == 1
221
+
222
+ os.chdir(original_dir)
@@ -0,0 +1,228 @@
1
+ import os
2
+ import stat
3
+ import tempfile
4
+ import subprocess
5
+ from ghostnotes.hook import install_hook, strip_ghostnotes
6
+ from ghostnotes.config import create_config
7
+
8
+
9
+ # --- Helpers ---
10
+ def make_fake_git_dir():
11
+ tmp = tempfile.mkdtemp()
12
+ os.makedirs(os.path.join(tmp, ".git", "hooks"))
13
+ os.makedirs(os.path.join(tmp, ".git", "info"))
14
+ with open(os.path.join(tmp, ".git", "info", "exclude"), "w") as f:
15
+ f.write("")
16
+ return tmp
17
+ def make_real_git_repo():
18
+ tmp = tempfile.mkdtemp()
19
+ subprocess.run(["git", "init", tmp], capture_output=True)
20
+ subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=tmp, capture_output=True)
21
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=tmp, capture_output=True)
22
+ return tmp
23
+
24
+ def test_install_hook_creates_file():
25
+ tmp = make_fake_git_dir()
26
+ original_dir = os.getcwd()
27
+ os.chdir(tmp)
28
+
29
+ install_hook()
30
+
31
+ assert os.path.isfile(".git/hooks/pre-commit")
32
+
33
+ os.chdir(original_dir)
34
+
35
+
36
+ def test_install_hook_has_shebang():
37
+ tmp = make_fake_git_dir()
38
+ original_dir = os.getcwd()
39
+ os.chdir(tmp)
40
+
41
+ install_hook()
42
+
43
+ with open(".git/hooks/pre-commit", "r") as f:
44
+ first_line = f.readline().strip()
45
+ assert first_line == "#!/bin/bash"
46
+
47
+ os.chdir(original_dir)
48
+
49
+
50
+ def test_install_hook_has_command():
51
+ tmp = make_fake_git_dir()
52
+ original_dir = os.getcwd()
53
+ os.chdir(tmp)
54
+
55
+ install_hook()
56
+
57
+ with open(".git/hooks/pre-commit", "r") as f:
58
+ contents = f.read()
59
+ assert "python3 -m ghostnotes.hook" in contents
60
+
61
+ os.chdir(original_dir)
62
+
63
+
64
+ def test_install_hook_is_executable():
65
+ tmp = make_fake_git_dir()
66
+ original_dir = os.getcwd()
67
+ os.chdir(tmp)
68
+
69
+ install_hook()
70
+
71
+ file_stat = os.stat(".git/hooks/pre-commit")
72
+ assert file_stat.st_mode & stat.S_IEXEC
73
+
74
+ os.chdir(original_dir)
75
+
76
+
77
+ def test_install_hook_appends_to_existing(): # crucial to not mess up user's previous hooks and workflows
78
+ tmp = make_fake_git_dir()
79
+ original_dir = os.getcwd()
80
+ os.chdir(tmp)
81
+
82
+ # create an existing pre-commit hook with some other command
83
+ with open(".git/hooks/pre-commit", "w") as f:
84
+ f.write("#!/bin/bash\necho 'existing hook'\n")
85
+
86
+ install_hook()
87
+
88
+ with open(".git/hooks/pre-commit", "r") as f:
89
+ contents = f.read()
90
+
91
+ # both the old content and the new command should be there
92
+ assert "existing hook" in contents
93
+ assert "python3 -m ghostnotes.hook" in contents
94
+
95
+ os.chdir(original_dir)
96
+
97
+
98
+ def test_install_hook_no_duplicate():
99
+ tmp = make_fake_git_dir()
100
+ original_dir = os.getcwd()
101
+ os.chdir(tmp)
102
+
103
+ install_hook()
104
+ install_hook()
105
+
106
+ with open(".git/hooks/pre-commit", "r") as f:
107
+ contents = f.read()
108
+
109
+ count = contents.count("python3 -m ghostnotes.hook")
110
+ assert count == 1
111
+
112
+ os.chdir(original_dir)
113
+
114
+
115
+ # these tests use a real git repo because strip_ghostnotes()
116
+ # runs actual git commands (git diff, git show, git hash-object, etc.)
117
+
118
+ def test_strip_removes_ghost_note_from_index(): # crucial
119
+ tmp = make_real_git_repo()
120
+ original_dir = os.getcwd()
121
+ os.chdir(tmp)
122
+
123
+ create_config()
124
+
125
+ # create a python file with a ghost note comment
126
+ with open("example.py", "w") as f:
127
+ f.write("x = 1\n")
128
+ f.write("y = 2 # GN: this is a ghost note\n")
129
+ f.write("z = 3\n")
130
+
131
+ # stage the file (add it to the git index)
132
+ subprocess.run(["git", "add", "example.py"], capture_output=True)
133
+
134
+ strip_ghostnotes()
135
+
136
+ # read what's now in the git index (staged version) after stripping
137
+ result = subprocess.run(
138
+ ["git", "show", ":example.py"],
139
+ capture_output=True, text=True
140
+ )
141
+ staged_content = result.stdout
142
+
143
+ assert "GN:" not in staged_content
144
+ assert "y = 2" in staged_content
145
+ assert "x = 1" in staged_content
146
+ assert "z = 3" in staged_content
147
+
148
+ os.chdir(original_dir)
149
+
150
+
151
+ def test_strip_leaves_non_tagged_comments(): # crucial
152
+ tmp = make_real_git_repo()
153
+ original_dir = os.getcwd()
154
+ os.chdir(tmp)
155
+
156
+ create_config()
157
+
158
+ with open("example.py", "w") as f:
159
+ f.write("# this is a normal comment\n")
160
+ f.write("x = 1 # GN: ghost note to remove\n")
161
+ f.write("y = 2 # regular inline comment\n")
162
+
163
+ subprocess.run(["git", "add", "example.py"], capture_output=True)
164
+
165
+ strip_ghostnotes()
166
+
167
+ result = subprocess.run(
168
+ ["git", "show", ":example.py"],
169
+ capture_output=True, text=True
170
+ )
171
+ staged_content = result.stdout
172
+
173
+ assert "# this is a normal comment" in staged_content
174
+ assert "# regular inline comment" in staged_content
175
+ assert "GN:" not in staged_content
176
+
177
+ os.chdir(original_dir)
178
+
179
+
180
+ def test_strip_ignores_unsupported_files():
181
+ tmp = make_real_git_repo()
182
+ original_dir = os.getcwd()
183
+ os.chdir(tmp)
184
+
185
+ create_config()
186
+
187
+ with open("notes.txt", "w") as f:
188
+ f.write("some text # GN: this should stay\n")
189
+
190
+ subprocess.run(["git", "add", "notes.txt"], capture_output=True)
191
+
192
+ strip_ghostnotes()
193
+
194
+ result = subprocess.run(
195
+ ["git", "show", ":notes.txt"],
196
+ capture_output=True, text=True
197
+ )
198
+
199
+ assert "# GN: this should stay" in result.stdout
200
+
201
+ os.chdir(original_dir)
202
+
203
+
204
+ def test_strip_works_with_js_files():
205
+ tmp = make_real_git_repo()
206
+ original_dir = os.getcwd()
207
+ os.chdir(tmp)
208
+
209
+ create_config()
210
+
211
+ with open("app.js", "w") as f:
212
+ f.write("const x = 1;\n")
213
+ f.write("const y = 2; // GN: remember to refactor\n")
214
+
215
+ subprocess.run(["git", "add", "app.js"], capture_output=True)
216
+
217
+ strip_ghostnotes()
218
+
219
+ result = subprocess.run(
220
+ ["git", "show", ":app.js"],
221
+ capture_output=True, text=True
222
+ )
223
+ staged_content = result.stdout
224
+
225
+ assert "GN:" not in staged_content
226
+ assert "const y = 2;" in staged_content
227
+
228
+ os.chdir(original_dir)
@@ -0,0 +1,282 @@
1
+ import os
2
+ import tempfile
3
+ import configparser
4
+
5
+ from ghostnotes.config import create_config
6
+ from ghostnotes.sync import extract_notes, strip_working_tree, reapply_notes
7
+
8
+
9
+ # --- Helper ---
10
+ def make_project():
11
+ tmp = tempfile.mkdtemp()
12
+ os.makedirs(os.path.join(tmp, ".git", "info"))
13
+ with open(os.path.join(tmp, ".git", "info", "exclude"), "w") as f:
14
+ f.write("")
15
+ return tmp
16
+
17
+ def test_extract_notes_finds_ghost_note():
18
+ tmp = make_project()
19
+ original_dir = os.getcwd()
20
+ os.chdir(tmp)
21
+
22
+ create_config()
23
+
24
+ with open("example.py", "w") as f:
25
+ f.write("x = 1\n")
26
+ f.write("y = 2 # GN: remember this\n")
27
+
28
+ notes = extract_notes()
29
+
30
+ assert "example.py" in notes
31
+ assert len(notes["example.py"]) == 1
32
+ assert notes["example.py"][0]["note"] == "remember this"
33
+
34
+ os.chdir(original_dir)
35
+
36
+
37
+ def test_extract_notes_gets_line_number():
38
+ tmp = make_project()
39
+ original_dir = os.getcwd()
40
+ os.chdir(tmp)
41
+
42
+ create_config()
43
+
44
+ with open("example.py", "w") as f:
45
+ f.write("x = 1\n") # line 0
46
+ f.write("y = 2\n") # line 1
47
+ f.write("z = 3 # GN: note on line 2\n") # line 2
48
+
49
+ notes = extract_notes()
50
+
51
+ assert notes["example.py"][0]["line_number"] == 2
52
+
53
+ os.chdir(original_dir)
54
+
55
+
56
+ def test_extract_notes_gets_stripped_line():
57
+ tmp = make_project()
58
+ original_dir = os.getcwd()
59
+ os.chdir(tmp)
60
+
61
+ create_config()
62
+
63
+ with open("example.py", "w") as f:
64
+ f.write("x = 1 # GN: my note\n")
65
+
66
+ notes = extract_notes()
67
+
68
+ assert notes["example.py"][0]["stripped_line"] == "x = 1"
69
+
70
+ os.chdir(original_dir)
71
+
72
+
73
+ def test_extract_notes_ignores_unsupported_files():
74
+ tmp = make_project()
75
+ original_dir = os.getcwd()
76
+ os.chdir(tmp)
77
+
78
+ create_config()
79
+
80
+ with open("notes.txt", "w") as f:
81
+ f.write("hello # GN: should be ignored\n")
82
+
83
+ notes = extract_notes()
84
+
85
+ assert "notes.txt" not in notes
86
+
87
+ os.chdir(original_dir)
88
+
89
+
90
+ def test_extract_notes_skips_hidden_dirs():
91
+ tmp = make_project()
92
+ original_dir = os.getcwd()
93
+ os.chdir(tmp)
94
+
95
+ create_config()
96
+
97
+ with open(os.path.join(".git", "test.py"), "w") as f:
98
+ f.write("x = 1 # GN: hidden note\n")
99
+
100
+ notes = extract_notes()
101
+
102
+ assert len(notes) == 0
103
+
104
+ os.chdir(original_dir)
105
+
106
+
107
+ def test_extract_notes_finds_multiple_notes():
108
+ tmp = make_project()
109
+ original_dir = os.getcwd()
110
+ os.chdir(tmp)
111
+
112
+ create_config()
113
+
114
+ with open("example.py", "w") as f:
115
+ f.write("x = 1 # GN: first note\n")
116
+ f.write("y = 2\n")
117
+ f.write("z = 3 # GN: second note\n")
118
+
119
+ notes = extract_notes()
120
+
121
+ assert len(notes["example.py"]) == 2
122
+
123
+ os.chdir(original_dir)
124
+
125
+
126
+ def test_strip_working_tree_removes_notes():
127
+ tmp = make_project()
128
+ original_dir = os.getcwd()
129
+ os.chdir(tmp)
130
+
131
+ create_config()
132
+
133
+ with open("example.py", "w") as f:
134
+ f.write("x = 1 # GN: my note\n")
135
+ f.write("y = 2\n")
136
+
137
+ notes = extract_notes()
138
+ strip_working_tree(notes)
139
+
140
+ with open("example.py", "r") as f:
141
+ content = f.read()
142
+
143
+ assert "GN:" not in content
144
+ assert "x = 1\n" in content
145
+ assert "y = 2\n" in content
146
+
147
+ os.chdir(original_dir)
148
+
149
+
150
+ def test_strip_working_tree_keeps_other_lines():
151
+ tmp = make_project()
152
+ original_dir = os.getcwd()
153
+ os.chdir(tmp)
154
+
155
+ create_config()
156
+
157
+ with open("example.py", "w") as f:
158
+ f.write("# a normal comment\n")
159
+ f.write("x = 1 # GN: remove me\n")
160
+ f.write("y = 2 # regular comment\n")
161
+
162
+ notes = extract_notes()
163
+ strip_working_tree(notes)
164
+
165
+ with open("example.py", "r") as f:
166
+ content = f.read()
167
+
168
+ assert "# a normal comment" in content
169
+ assert "# regular comment" in content
170
+ assert "GN:" not in content
171
+
172
+ os.chdir(original_dir)
173
+
174
+
175
+ def test_reapply_notes_restores_notes():
176
+ tmp = make_project()
177
+ original_dir = os.getcwd()
178
+ os.chdir(tmp)
179
+
180
+ create_config()
181
+ config = configparser.ConfigParser()
182
+ config.read(".ghostnotes")
183
+
184
+ with open("example.py", "w") as f:
185
+ f.write("x = 1 # GN: my note\n")
186
+ f.write("y = 2\n")
187
+
188
+ notes = extract_notes()
189
+ strip_working_tree(notes)
190
+ reapply_notes(notes, config)
191
+
192
+ with open("example.py", "r") as f:
193
+ content = f.read()
194
+
195
+ # the note should be back
196
+ assert "# GN: my note" in content
197
+ assert "x = 1" in content
198
+
199
+ os.chdir(original_dir)
200
+
201
+
202
+ def test_reapply_notes_handles_shifted_lines():
203
+ tmp = make_project()
204
+ original_dir = os.getcwd()
205
+ os.chdir(tmp)
206
+
207
+ create_config()
208
+ config = configparser.ConfigParser()
209
+ config.read(".ghostnotes")
210
+
211
+ with open("example.py", "w") as f:
212
+ f.write("x = 1 # GN: my note\n")
213
+
214
+ notes = extract_notes()
215
+ strip_working_tree(notes)
216
+
217
+ with open("example.py", "r") as f:
218
+ lines = f.readlines()
219
+ lines.insert(0, "# new line from pull\n")
220
+ with open("example.py", "w") as f:
221
+ f.writelines(lines)
222
+
223
+ reapply_notes(notes, config)
224
+
225
+ with open("example.py", "r") as f:
226
+ content = f.read()
227
+
228
+ assert "# GN: my note" in content
229
+
230
+ os.chdir(original_dir)
231
+
232
+
233
+ def test_reapply_notes_reports_orphaned(capsys):
234
+ tmp = make_project()
235
+ original_dir = os.getcwd()
236
+ os.chdir(tmp)
237
+
238
+ create_config()
239
+ config = configparser.ConfigParser()
240
+ config.read(".ghostnotes")
241
+
242
+ with open("example.py", "w") as f:
243
+ f.write("x = 1 # GN: my note\n")
244
+
245
+ notes = extract_notes()
246
+
247
+ with open("example.py", "w") as f:
248
+ f.write("totally_different = True\n")
249
+
250
+ reapply_notes(notes, config)
251
+
252
+ captured = capsys.readouterr()
253
+ assert "ORPHANED" in captured.out
254
+
255
+ os.chdir(original_dir)
256
+
257
+
258
+ def test_reapply_notes_reports_deleted_file(capsys):
259
+ """Does reapply_notes() print ORPHANED when the file was deleted?"""
260
+ tmp = make_project()
261
+ original_dir = os.getcwd()
262
+ os.chdir(tmp)
263
+
264
+ create_config()
265
+ config = configparser.ConfigParser()
266
+ config.read(".ghostnotes")
267
+
268
+ with open("example.py", "w") as f:
269
+ f.write("x = 1 # GN: my note\n")
270
+
271
+ notes = extract_notes()
272
+
273
+ # delete the file entirely
274
+ os.remove("example.py")
275
+
276
+ reapply_notes(notes, config)
277
+
278
+ captured = capsys.readouterr()
279
+ assert "ORPHANED" in captured.out
280
+ assert "file deleted" in captured.out
281
+
282
+ os.chdir(original_dir)