Skip to content

Commit 4796e57

Browse files
authored
feat: better dry-run/diff preview with unified diffs (#103)
Closes #93 - Add `--diff` option to generate command showing unified diffs for files that would be created/modified. - Support diff mode in both `--dry-run` and console output modes. - Print per-file action summary (create/update) during dry-run with diff. - Normalized line ending handling for clean diff output. - Added test coverage for dry-run diff behavior. All tests pass (93).
1 parent f278b93 commit 4796e57

2 files changed

Lines changed: 77 additions & 9 deletions

File tree

struct_module/commands/generate.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def __init__(self, parser):
1818
parser.add_argument('-s', '--structures-path', type=str, help='Path to structure definitions')
1919
parser.add_argument('-n', '--input-store', type=str, help='Path to the input store', default='/tmp/struct/input.json')
2020
parser.add_argument('-d', '--dry-run', action='store_true', help='Perform a dry run without creating any files or directories')
21+
parser.add_argument('--diff', action='store_true', help='Show unified diffs for files that would change during dry-run or console output')
2122
parser.add_argument('-v', '--vars', type=str, help='Template variables in the format KEY1=value1,KEY2=value2')
2223
parser.add_argument('-b', '--backup', type=str, help='Path to the backup folder')
2324
parser.add_argument('-f', '--file-strategy', type=str, choices=['overwrite', 'skip', 'append', 'rename', 'backup'], default='overwrite', help='Strategy for handling existing files').completer = file_strategy_completer
@@ -189,18 +190,47 @@ def _create_structure(self, args, mappings=None):
189190
)
190191
file_item.apply_template_variables(template_vars)
191192

192-
# Output mode logic
193+
# Output mode logic with diff support
193194
if hasattr(args, 'output') and args.output == 'console':
194-
# Print the file path and content to the console instead of creating the file
195195
print(f"=== {file_path_to_create} ===")
196-
print(file_item.content)
196+
if args.diff and existing_content is not None:
197+
import difflib
198+
new_content = file_item.content if file_item.content.endswith("\n") else file_item.content + "\n"
199+
old_content = existing_content if existing_content.endswith("\n") else existing_content + "\n"
200+
diff = difflib.unified_diff(
201+
old_content.splitlines(keepends=True),
202+
new_content.splitlines(keepends=True),
203+
fromfile=f"a/{file_path_to_create}",
204+
tofile=f"b/{file_path_to_create}",
205+
)
206+
print("".join(diff))
207+
else:
208+
print(file_item.content)
197209
else:
198-
file_item.create(
199-
args.base_path,
200-
args.dry_run or False,
201-
args.backup or None,
202-
args.file_strategy or 'overwrite'
203-
)
210+
# When dry-run with --diff and files mode, print action and diff instead of writing
211+
if args.dry_run and args.diff:
212+
action = "create"
213+
if existing_content is not None:
214+
action = "update"
215+
print(f"[DRY RUN] {action}: {file_path_to_create}")
216+
import difflib
217+
new_content = file_item.content if file_item.content.endswith("\n") else file_item.content + "\n"
218+
old_content = (existing_content if existing_content is not None else "")
219+
old_content = old_content if old_content.endswith("\n") else (old_content + ("\n" if old_content else ""))
220+
diff = difflib.unified_diff(
221+
old_content.splitlines(keepends=True),
222+
new_content.splitlines(keepends=True),
223+
fromfile=f"a/{file_path_to_create}",
224+
tofile=f"b/{file_path_to_create}",
225+
)
226+
print("".join(diff))
227+
else:
228+
file_item.create(
229+
args.base_path,
230+
args.dry_run or False,
231+
args.backup or None,
232+
args.file_strategy or 'overwrite'
233+
)
204234

205235
for item in config_folders:
206236
for folder, content in item.items():

tests/test_commands_more.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,44 @@ def test_generate_creates_base_path_and_console_output(parser, tmp_path):
5050
mock_makedirs.assert_called() # base path created
5151

5252

53+
def test_generate_dry_run_diff_shows_unified_diff(parser, tmp_path):
54+
command = GenerateCommand(parser)
55+
args = parser.parse_args(['struct-x', str(tmp_path / 'base')])
56+
57+
# Minimal config to trigger one file update
58+
config = {'files': [{'hello.txt': 'Hello world'}], 'folders': []}
59+
60+
# Existing file with different content
61+
base_dir = tmp_path / 'base'
62+
base_dir.mkdir(parents=True, exist_ok=True)
63+
(base_dir / 'hello.txt').write_text('Hello old\n')
64+
65+
store_dir = tmp_path / 'store'
66+
store_dir.mkdir(parents=True, exist_ok=True)
67+
with open(store_dir / 'input.json', 'w') as fh:
68+
fh.write('{}')
69+
70+
with patch.object(command, '_load_yaml_config', return_value=config), \
71+
patch('builtins.print') as mock_print:
72+
args.output = 'file'
73+
args.input_store = str(store_dir / 'input.json')
74+
args.dry_run = True
75+
args.diff = True
76+
args.vars = None
77+
args.backup = None
78+
args.file_strategy = 'overwrite'
79+
args.global_system_prompt = None
80+
args.structures_path = None
81+
args.non_interactive = True
82+
83+
command.execute(args)
84+
85+
# Should have printed a DRY RUN action and diff
86+
printed = ''.join(call.args[0] for call in mock_print.call_args_list)
87+
assert '[DRY RUN] update' in printed
88+
assert '--- a' in printed and '+++ b' in printed
89+
90+
5391
def test_generate_pre_hook_failure_aborts(parser, tmp_path):
5492
command = GenerateCommand(parser)
5593
args = parser.parse_args(['struct-x', str(tmp_path)])

0 commit comments

Comments
 (0)