From e5fdbd8754551d2a8d150bf356f2cb6499902f81 Mon Sep 17 00:00:00 2001 From: FiniteSkills Date: Fri, 3 Jul 2026 08:15:23 +0530 Subject: [PATCH] complete snapshot refs in shells --- quicksave/cli.py | 105 ++++++++++++++++++++++++++++++++++++++++++---- tests/test_cli.py | 36 ++++++++++++++++ 2 files changed, 134 insertions(+), 7 deletions(-) diff --git a/quicksave/cli.py b/quicksave/cli.py index 5810adc..5080936 100644 --- a/quicksave/cli.py +++ b/quicksave/cli.py @@ -1428,48 +1428,107 @@ def cmd_hook(args): store.gc(root, keep=keep) +_REF_COMPLETION_CASES = ( + "restore:1", "log:1", "diff:1", "diff:2", "show:1", + "name:1", "pin:1", "unpin:1", "status:1", "drop:*", +) + _BASH_COMPLETION = """\ _quicksave() { - local cur cmds + local cur cmds sub pos refs cur="${COMP_WORDS[COMP_CWORD]}" cmds="__CMDS__" + sub="${COMP_WORDS[1]}" + pos=$((COMP_CWORD - 1)) if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "$cmds" -- "$cur") ) elif [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "-q --quiet --json --dry-run --help" -- "$cur") ) + elif _quicksave_ref_position "$sub" "$pos"; then + refs="$(quicksave __complete-refs 2>/dev/null)" + COMPREPLY=( $(compgen -W "$refs" -- "$cur") ) else COMPREPLY=( $(compgen -f -- "$cur") ) fi } +_quicksave_ref_position() { + case "$1:$2" in + __REF_CASES__) return 0 ;; + esac + return 1 +} complete -F _quicksave quicksave """ _ZSH_COMPLETION = """\ #compdef quicksave _quicksave() { - local -a cmds + local -a cmds refs + local sub pos cmds=(__CMDS__) + sub=$words[2] + pos=$(( CURRENT - 2 )) if (( CURRENT == 2 )); then compadd -- $cmds elif [[ $words[CURRENT] == -* ]]; then compadd -- -q --quiet --json --dry-run --help + elif _quicksave_ref_position "$sub" "$pos"; then + refs=("${(@f)$(quicksave __complete-refs 2>/dev/null)}") + compadd -- $refs else _files fi } +_quicksave_ref_position() { + case "$1:$2" in + __REF_CASES__) return 0 ;; + esac + return 1 +} compdef _quicksave quicksave """ _FISH_COMPLETION = """\ +function __quicksave_ref_position + set -l cur (commandline -ct) + if string match -q -- '-*' $cur + return 1 + end + set -l tokens (commandline -opc) + set -l sub $tokens[2] + set -l pos (math (count $tokens) - 1) + if test -n "$cur" + set pos (math $pos - 1) + end + switch "$sub:$pos" + case __REF_CASES_FISH__ + return 0 + end + return 1 +end complete -c quicksave -f -n __fish_use_subcommand -a "__CMDS__" complete -c quicksave -l help -l json -l dry-run -l quiet -s q -complete -c quicksave -n 'not __fish_use_subcommand' -F +complete -c quicksave -f -n '__quicksave_ref_position' -a '(quicksave __complete-refs 2>/dev/null)' +complete -c quicksave -n 'not __fish_use_subcommand; and not __quicksave_ref_position' -F """ _PWSH_COMPLETION = """\ Register-ArgumentCompleter -Native -CommandName quicksave -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) $cmds = '__CMDS__'.Split(' ') - # element 0 is 'quicksave' itself, so <= 2 elements means we're on the subcommand - if ($commandAst.CommandElements.Count -le 2) { + function Test-QuicksaveRefPosition($Sub, $Pos) { + $refCases = @( + __REF_CASES_PWSH__ + ) + foreach ($Pattern in $refCases) { + if ("${Sub}:${Pos}" -like $Pattern) { + return $true + } + } + return $false + } + # element 0 is 'quicksave' itself. With a trailing space after the + # subcommand, PowerShell still reports two elements, so an empty word there + # belongs to the first positional argument. + if ($commandAst.CommandElements.Count -lt 2 -or ($commandAst.CommandElements.Count -eq 2 -and $wordToComplete)) { $cmds | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } @@ -1481,6 +1540,15 @@ def cmd_hook(args): [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) } } + else { + $sub = $commandAst.CommandElements[1].Extent.Text + $pos = [Math]::Max(1, $commandAst.CommandElements.Count - 2) + if (Test-QuicksaveRefPosition $sub $pos) { + & quicksave __complete-refs 2>$null | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + } } """ @@ -1488,16 +1556,36 @@ def _subcommands(): parser = build_parser() for action in parser._actions: if isinstance(action, argparse._SubParsersAction): - return sorted(action.choices) + return sorted(name for name in action.choices if not name.startswith("__")) return [] def cmd_completion(args): cmds = " ".join(_subcommands()) + ref_cases = "|".join(_REF_COMPLETION_CASES) + ref_cases_fish = " ".join(_REF_COMPLETION_CASES) + ref_cases_pwsh = ",\n ".join(f"'{case}'" for case in _REF_COMPLETION_CASES) scripts = {"bash": _BASH_COMPLETION, "zsh": _ZSH_COMPLETION, "fish": _FISH_COMPLETION, "powershell": _PWSH_COMPLETION} # plain print so 'eval "$(quicksave completion bash)"' gets a clean script - print(scripts[args.shell].replace("__CMDS__", cmds), end="") + script = scripts[args.shell].replace("__CMDS__", cmds) + script = script.replace("__REF_CASES__", ref_cases) + script = script.replace("__REF_CASES_FISH__", ref_cases_fish) + script = script.replace("__REF_CASES_PWSH__", ref_cases_pwsh) + print(script, end="") + + +def cmd_complete_refs(args): + root = store.find_root() + if root is None: + return + seen = set() + for snap in store.list_snapshots(root): + refs = (str(snap["seq"]), snap["id"], snap.get("name") or "") + for ref in refs: + if ref and ref not in seen: + seen.add(ref) + print(ref) def cmd_hook_install(args): @@ -1856,6 +1944,9 @@ def build_parser(): help="which shell to emit a completion script for") pcomp.set_defaults(func=cmd_completion) + pref = sub.add_parser("__complete-refs", help=argparse.SUPPRESS, parents=[common]) + pref.set_defaults(func=cmd_complete_refs) + phook = sub.add_parser("hook", help="PreToolUse hook: auto-save before a risky bash command", parents=[common]) phook.add_argument("--check", metavar="CMD", help="print whether CMD would trigger a snapshot, then exit (0 risky, 1 safe); " diff --git a/tests/test_cli.py b/tests/test_cli.py index 91d5c3e..b30fbbf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2594,6 +2594,9 @@ def test_completion_bash_lists_subcommands(capsys): assert "completion" in out # past the subcommand it offers the shared flags before file completion assert "--dry-run" in out + assert "quicksave __complete-refs" in out + assert "restore:1" in out + assert "__complete-refs" not in out.split('cmds="', 1)[1].split('"', 1)[0].split() def test_completion_zsh_wires_compdef(capsys): @@ -2603,6 +2606,8 @@ def test_completion_zsh_wires_compdef(capsys): assert "compdef _quicksave quicksave" in out assert "restore" in out assert "--dry-run" in out + assert "quicksave __complete-refs" in out + assert "diff:2" in out def test_completion_fish_lists_subcommands(capsys): main(["completion", "fish"]) @@ -2611,6 +2616,8 @@ def test_completion_fish_lists_subcommands(capsys): assert "__fish_use_subcommand" in out assert "restore" in out assert "-l dry-run" in out + assert "__quicksave_ref_position" in out + assert "quicksave __complete-refs" in out def test_completion_powershell_registers_completer(capsys): @@ -2619,6 +2626,35 @@ def test_completion_powershell_registers_completer(capsys): assert "Register-ArgumentCompleter -Native -CommandName quicksave" in out assert "restore" in out assert "--dry-run" in out + assert "Test-QuicksaveRefPosition" in out + assert "quicksave __complete-refs" in out + + +def test_complete_refs_prints_snapshot_numbers_ids_and_names(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + main(["init"]) + (tmp_path / "a.txt").write_text("one") + main(["save", "-m", "first", "-n", "good"]) + (tmp_path / "a.txt").write_text("two") + main(["save", "-m", "second"]) + capsys.readouterr() + + main(["__complete-refs"]) + refs = capsys.readouterr().out.splitlines() + snaps = store.list_snapshots(tmp_path) + + assert str(snaps[0]["seq"]) in refs + assert snaps[0]["id"] in refs + assert "good" in refs + assert str(snaps[1]["seq"]) in refs + assert snaps[1]["id"] in refs + assert "" not in refs + + +def test_complete_refs_outside_project_is_quiet(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + main(["__complete-refs"]) + assert capsys.readouterr().out == "" # --------------------------------------------------------------------------- # find --changes --diff