@@ -84,22 +84,69 @@ def list_files() -> None:
8484
8585@main .command ()
8686@click .option ("--message" , "-m" , default = None , help = "Commit message for the backup." )
87+ @click .option (
88+ "--dry-run" , is_flag = True , help = "Preview what would be backed up without making changes."
89+ )
90+ @click .option (
91+ "--conflict-strategy" ,
92+ type = click .Choice (["skip" , "keep-local" , "keep-repo" , "keep-newer" , "make-backup" , "abort" ]),
93+ default = "make-backup" ,
94+ help = "How to handle sync conflicts." ,
95+ )
8796@_handle_error
88- def backup (message : str | None ) -> None :
97+ def backup (message : str | None , dry_run : bool , conflict_strategy : str ) -> None :
8998 """Copy tracked files into the repo and commit."""
99+ from .conflicts import ConflictStrategy
100+
101+ strategy_map = {
102+ "skip" : ConflictStrategy .SKIP ,
103+ "keep-local" : ConflictStrategy .KEEP_LOCAL ,
104+ "keep-repo" : ConflictStrategy .KEEP_REPO ,
105+ "keep-newer" : ConflictStrategy .KEEP_NEWER ,
106+ "make-backup" : ConflictStrategy .MAKE_BACKUP ,
107+ "abort" : ConflictStrategy .ABORT ,
108+ }
90109 sync = DotfileSync ()
91- result = sync .backup (message = message )
110+ result = sync .backup (
111+ message = message ,
112+ dry_run = dry_run ,
113+ conflict_strategy = strategy_map [conflict_strategy ],
114+ )
92115 console .print (Panel (result , title = "💾 Backup" , border_style = "blue" ))
93116
94117
95118@main .command ()
96119@click .option ("--only" , "-o" , default = None , help = "Restore only a specific file path." )
97120@click .option ("--no-render" , is_flag = True , help = "Skip template rendering, write raw content." )
121+ @click .option (
122+ "--dry-run" , is_flag = True , help = "Preview what would be restored without making changes."
123+ )
124+ @click .option (
125+ "--conflict-strategy" ,
126+ type = click .Choice (["skip" , "keep-local" , "keep-repo" , "keep-newer" , "make-backup" , "abort" ]),
127+ default = "make-backup" ,
128+ help = "How to handle sync conflicts." ,
129+ )
98130@_handle_error
99- def restore (only : str | None , no_render : bool ) -> None :
131+ def restore (only : str | None , no_render : bool , dry_run : bool , conflict_strategy : str ) -> None :
100132 """Copy files from the repo back to their original locations."""
133+ from .conflicts import ConflictStrategy
134+
135+ strategy_map = {
136+ "skip" : ConflictStrategy .SKIP ,
137+ "keep-local" : ConflictStrategy .KEEP_LOCAL ,
138+ "keep-repo" : ConflictStrategy .KEEP_REPO ,
139+ "keep-newer" : ConflictStrategy .KEEP_NEWER ,
140+ "make-backup" : ConflictStrategy .MAKE_BACKUP ,
141+ "abort" : ConflictStrategy .ABORT ,
142+ }
101143 sync = DotfileSync ()
102- result = sync .restore (only = only , render = not no_render )
144+ result = sync .restore (
145+ only = only ,
146+ render = not no_render ,
147+ dry_run = dry_run ,
148+ conflict_strategy = strategy_map [conflict_strategy ],
149+ )
103150 console .print (Panel (result , title = "📂 Restore" , border_style = "green" ))
104151
105152
@@ -395,5 +442,188 @@ def profile_show() -> None:
395442 console .print (f" Machine name: [cyan]{ machine } [/cyan]" )
396443
397444
398- if __name__ == "__main__" :
399- main ()
445+ # --- Hook Commands ---
446+
447+
448+ @main .group ()
449+ def hook () -> None :
450+ """Manage hooks (commands that run before/after sync operations)."""
451+ pass
452+
453+
454+ @hook .command ("list" )
455+ @_handle_error
456+ def hook_list () -> None :
457+ """List all configured hooks."""
458+ from .hooks import create_hook_config
459+
460+ sync = DotfileSync ()
461+ sync ._ensure_initialized ()
462+ config = create_hook_config (sync .repo_dir )
463+ all_hooks = config .list_all ()
464+
465+ if not all_hooks :
466+ console .print ("[yellow]No hooks configured[/yellow]" )
467+ return
468+
469+ console .print ("[bold]Configured hooks:[/bold]" )
470+ for event_name , commands in sorted (all_hooks .items ()):
471+ console .print (f" [cyan]{ event_name } [/cyan]:" )
472+ for i , cmd in enumerate (commands , 1 ):
473+ console .print (f" { i } . { cmd } " )
474+
475+
476+ @hook .command ("set" )
477+ @click .argument (
478+ "event" ,
479+ type = click .Choice ([
480+ "pre-backup" ,
481+ "post-backup" ,
482+ "pre-restore" ,
483+ "post-restore" ,
484+ "pre-track" ,
485+ "post-track" ,
486+ ]),
487+ )
488+ @click .argument ("command" )
489+ @_handle_error
490+ def hook_set (event : str , command : str ) -> None :
491+ """Add a hook command for an event.
492+
493+ \b
494+ Events: pre-backup, post-backup, pre-restore, post-restore,
495+ pre-track, post-track
496+ """
497+ from .hooks import HookEvent , create_hook_config
498+
499+ event_map = {
500+ "pre-backup" : HookEvent .PRE_BACKUP ,
501+ "post-backup" : HookEvent .POST_BACKUP ,
502+ "pre-restore" : HookEvent .PRE_RESTORE ,
503+ "post-restore" : HookEvent .POST_RESTORE ,
504+ "pre-track" : HookEvent .PRE_TRACK ,
505+ "post-track" : HookEvent .POST_TRACK ,
506+ }
507+
508+ sync = DotfileSync ()
509+ sync ._ensure_initialized ()
510+ config = create_hook_config (sync .repo_dir )
511+ config .add_hook (event_map [event ], command )
512+ console .print (f"[green]✓[/green] Hook added: { event } → { command } " )
513+
514+
515+ @hook .command ("remove" )
516+ @click .argument (
517+ "event" ,
518+ type = click .Choice ([
519+ "pre-backup" ,
520+ "post-backup" ,
521+ "pre-restore" ,
522+ "post-restore" ,
523+ "pre-track" ,
524+ "post-track" ,
525+ ]),
526+ )
527+ @click .argument ("index" , type = int )
528+ @_handle_error
529+ def hook_remove (event : str , index : int ) -> None :
530+ """Remove a hook by its index."""
531+ from .hooks import HookEvent , create_hook_config
532+
533+ event_map = {
534+ "pre-backup" : HookEvent .PRE_BACKUP ,
535+ "post-backup" : HookEvent .POST_BACKUP ,
536+ "pre-restore" : HookEvent .PRE_RESTORE ,
537+ "post-restore" : HookEvent .POST_RESTORE ,
538+ "pre-track" : HookEvent .PRE_TRACK ,
539+ "post-track" : HookEvent .POST_TRACK ,
540+ }
541+
542+ sync = DotfileSync ()
543+ sync ._ensure_initialized ()
544+ config = create_hook_config (sync .repo_dir )
545+ if config .remove_hook (event_map [event ], index - 1 ): # 1-indexed display
546+ console .print (f"[yellow]✗[/yellow] Removed hook #{ index } from { event } " )
547+ else :
548+ console .print (f"[yellow]Invalid hook index: { index } [/yellow]" )
549+
550+
551+ # --- Ignore Commands ---
552+
553+
554+ @main .group ()
555+ def ignore () -> None :
556+ """Manage ignore patterns (like .gitignore)."""
557+ pass
558+
559+
560+ @ignore .command ("list" )
561+ @_handle_error
562+ def ignore_list () -> None :
563+ """List all active ignore patterns."""
564+ from .ignore import create_ignore_matcher
565+
566+ sync = DotfileSync ()
567+ sync ._ensure_initialized ()
568+ matcher = create_ignore_matcher (sync .repo_dir )
569+
570+ console .print (f"[bold]Ignore patterns ({ matcher .count ()} ):[/bold]" )
571+ for i , pattern in enumerate (matcher .patterns , 1 ):
572+ if not pattern .raw or pattern .raw .startswith ("#" ):
573+ continue
574+ label = ""
575+ if pattern .negated :
576+ label = " [dim](negated)[/dim]"
577+ elif pattern .directory_only :
578+ label = " [dim](dir)[/dim]"
579+ console .print (f" { i } . [cyan]{ pattern .raw } [/cyan]{ label } " )
580+
581+
582+ @ignore .command ("add" )
583+ @click .argument ("pattern" )
584+ @_handle_error
585+ def ignore_add (pattern : str ) -> None :
586+ """Add an ignore pattern. Supports gitignore syntax."""
587+ sync = DotfileSync ()
588+ sync ._ensure_initialized ()
589+
590+ # Append to .dotfileignore file
591+ ignore_file = sync .repo_dir / ".dotfileignore"
592+ try :
593+ with ignore_file .open ("a" ) as f :
594+ f .write (pattern + "\n " )
595+ except OSError as exc :
596+ raise DotfileSyncError (f"Failed to write ignore file: { exc } " ) from exc
597+
598+ console .print (f"[green]✓[/green] Added ignore pattern: { pattern } " )
599+
600+
601+ # --- Dry-Run Commands ---
602+
603+
604+ @main .command ("dry-run" )
605+ @click .option ("--operation" , type = click .Choice (["backup" , "restore" ]), default = "backup" )
606+ @click .option ("--only" , "-o" , default = None , help = "Only check this specific file." )
607+ @_handle_error
608+ def dry_run (operation : str , only : str | None ) -> None :
609+ """Preview what backup or restore would do without making changes."""
610+ from .dryrun import DryRunPreview , format_dry_run
611+ from .ignore import create_ignore_matcher
612+
613+ sync = DotfileSync ()
614+ sync ._ensure_initialized ()
615+
616+ ignore_matcher = create_ignore_matcher (sync .repo_dir )
617+ preview = DryRunPreview (sync )
618+
619+ if operation == "backup" :
620+ result = preview .preview_backup (ignore_matcher = ignore_matcher )
621+ else :
622+ result = preview .preview_restore (
623+ only = only ,
624+ render = True ,
625+ ignore_matcher = ignore_matcher ,
626+ )
627+
628+ formatted = format_dry_run (result )
629+ console .print (formatted )
0 commit comments