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.
- ghostnotes-1.0.0/LICENSE +21 -0
- ghostnotes-1.0.0/PKG-INFO +56 -0
- ghostnotes-1.0.0/README.md +45 -0
- ghostnotes-1.0.0/ghostnotes/__init__.py +0 -0
- ghostnotes-1.0.0/ghostnotes/cli.py +54 -0
- ghostnotes-1.0.0/ghostnotes/config.py +74 -0
- ghostnotes-1.0.0/ghostnotes/hook.py +103 -0
- ghostnotes-1.0.0/ghostnotes/sync.py +140 -0
- ghostnotes-1.0.0/ghostnotes.egg-info/PKG-INFO +56 -0
- ghostnotes-1.0.0/ghostnotes.egg-info/SOURCES.txt +16 -0
- ghostnotes-1.0.0/ghostnotes.egg-info/dependency_links.txt +1 -0
- ghostnotes-1.0.0/ghostnotes.egg-info/entry_points.txt +2 -0
- ghostnotes-1.0.0/ghostnotes.egg-info/top_level.txt +1 -0
- ghostnotes-1.0.0/pyproject.toml +17 -0
- ghostnotes-1.0.0/setup.cfg +4 -0
- ghostnotes-1.0.0/tests/test_config.py +222 -0
- ghostnotes-1.0.0/tests/test_hook.py +228 -0
- ghostnotes-1.0.0/tests/test_sync.py +282 -0
ghostnotes-1.0.0/LICENSE
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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)
|