shoka 0.21.1

A repository workspace manager — jj-aware, TUI-first successor to ghq / rhq.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Emit a shell wrapper that lets `shoka cd` / `shoka tui` actually
//! change the parent shell's working directory.
//!
//! A child process can't chdir its parent on any OS shoka cares
//! about — kernel-enforced. The standard workaround is a shell
//! function that captures shoka's resolved path and runs the
//! parent's own `cd` builtin on it. The function we emit goes one
//! step further: it claims the `shoka` *name itself* (instead of
//! the older `s` alias), intercepts the `cd` / `tui` subcommands,
//! and transparently passes every other shoka subcommand through to
//! the binary via `command shoka` / `& shoka.exe`. The user sees a
//! single `shoka` they can run any subcommand against; the wrapper
//! is invisible until they touch `cd` or `tui`.
//!
//! Each subcommand has a small twist:
//!
//! - **`shoka cd`** uses an interactive picker. `inquire` 0.9 writes
//!   its prompt UI to stdout with no public switch to stderr, so the
//!   wrapper can't capture stdout (would swallow the prompt).
//!   Instead it sets [`SHOKA_CD_OUT`] to a tmp file, lets stdout
//!   flow to the terminal, and reads the resolved path back from
//!   that file.
//! - **`shoka tui`** owns stdin/stdout for the ratatui dashboard;
//!   the path emission goes through the same sidechannel so the
//!   wrapper's logic is identical.
//! - **Everything else** is a plain pass-through to the binary.
//!
//! [`SHOKA_CD_OUT`]: crate::commands::cd::CD_OUT_ENV

use crate::cli::{InitShellArgs, SupportedShell};
use crate::commands::cd::CD_OUT_ENV;

pub async fn run(args: InitShellArgs) -> anyhow::Result<()> {
    let name = &args.name;
    let script = match args.shell {
        SupportedShell::Powershell => powershell_wrapper(name),
        SupportedShell::Bash | SupportedShell::Zsh => posix_wrapper(name),
        SupportedShell::Fish => fish_wrapper(name),
    };
    print!("{script}");
    Ok(())
}

fn posix_wrapper(name: &str) -> String {
    // `command shoka` bypasses the function lookup so the same name
    // can shadow the binary without infinite recursion.
    //
    // Bare `shoka` with no arguments defaults to `tui` so the most
    // common action (drop into the dashboard) is a single keystroke.
    // `set -- tui` rewrites the positional args; the `cd|tui` case
    // arm below then routes correctly.
    //
    // The exit code is held in `rc`, not `status`: this wrapper is
    // shared with zsh, where `status` is a readonly special parameter
    // (an alias for `$?`), so `local ... status` / `status=$?` would
    // abort with `read-only variable: status` (#146). `rc` is plain
    // in both bash and zsh.
    format!(
        r#"{name}() {{
    if [ $# -eq 0 ]; then
        set -- tui
    fi
    case "$1" in
        cd|tui)
            local tmp dest rc
            tmp=$(mktemp) || return 1
            {env}="$tmp" command shoka "$@"
            rc=$?
            if [ "$rc" -eq 0 ]; then
                dest=$(cat "$tmp")
            fi
            rm -f "$tmp"
            [ "$rc" -eq 0 ] && [ -n "$dest" ] && cd -- "$dest"
            return $rc
            ;;
        *)
            command shoka "$@"
            ;;
    esac
}}
"#,
        env = CD_OUT_ENV,
    )
}

fn fish_wrapper(name: &str) -> String {
    // Bare `shoka` defaults to `tui` (see posix_wrapper comment for
    // the rationale). fish's `set argv tui` rewrites the function's
    // arg list so the `case cd tui` arm picks it up cleanly.
    format!(
        r#"function {name}
    if test (count $argv) -eq 0
        set argv tui
    end
    switch "$argv[1]"
        case cd tui
            set -l tmp (mktemp); or return 1
            set -lx {env} $tmp
            command shoka $argv
            set -l rc $status
            set -l dest ""
            if test $rc -eq 0
                set dest (cat $tmp)
            end
            rm -f $tmp
            if test $rc -eq 0; and test -n "$dest"
                cd -- $dest
            end
            return $rc
        case '*'
            command shoka $argv
    end
end
"#,
        env = CD_OUT_ENV,
    )
}

fn powershell_wrapper(name: &str) -> String {
    // PowerShell needs the binary resolved explicitly because the
    // function name shadows the executable in command lookup. We
    // cache the resolved path in a `$script:` variable so the
    // `Get-Command` lookup (50-200 ms on Windows depending on PATH
    // length) runs once per session rather than on every wrapper
    // invocation — meaningful because the wrapper is now on the hot
    // path for every shoka subcommand. The cache invalidates
    // automatically on shell restart, which is also when a
    // `cargo install --force` would land a new binary.
    //
    // `Get-Command -CommandType Application` works on both Windows
    // (`shoka.exe`) and pwsh on Linux/macOS without a separate
    // code path.
    //
    // Bare `shoka` defaults to `tui`. **Do not** build the args via
    // `$effectiveArgs = if (...) { @('tui') } else { $args }` and
    // splat it: PowerShell silently unwraps single-element arrays on
    // scalar assignment, the variable becomes the string `'tui'`,
    // and `@$str` then iterates *characters*, invoking the binary
    // as `shoka.exe t u i` (errors with "unrecognized subcommand
    // 't'"). The explicit `& $exe tui` arm below avoids the trap.
    //
    // No try/finally around the pass-through branch — there's no
    // tmp file to clean up there, and a missing executable should
    // surface as PowerShell's standard error rather than be
    // swallowed.
    format!(
        r#"function {name} {{
    if (-not $script:ShokaExe) {{
        $script:ShokaExe = (Get-Command -Name shoka -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1).Source
    }}
    if (-not $script:ShokaExe) {{
        Write-Error 'shoka binary not found on PATH'
        return
    }}
    $first = if ($args.Count -gt 0) {{ $args[0] }} else {{ 'tui' }}
    if ($first -eq 'cd' -or $first -eq 'tui') {{
        $tmp = New-TemporaryFile
        try {{
            $env:{env} = $tmp.FullName
            if ($args.Count -eq 0) {{
                & $script:ShokaExe tui
            }} else {{
                & $script:ShokaExe @args
            }}
            $code = $LASTEXITCODE
            if ($code -eq 0) {{
                $dest = Get-Content -LiteralPath $tmp.FullName -Raw
                if ($dest) {{ Set-Location -LiteralPath $dest.TrimEnd() }}
            }}
            $global:LASTEXITCODE = $code
        }} finally {{
            Remove-Item -LiteralPath $tmp.FullName -Force -ErrorAction SilentlyContinue
            Remove-Item Env:{env} -ErrorAction SilentlyContinue
        }}
    }} else {{
        & $script:ShokaExe @args
    }}
}}
"#,
        env = CD_OUT_ENV,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::SupportedShell;

    fn args(shell: SupportedShell, name: &str) -> InitShellArgs {
        InitShellArgs {
            shell,
            name: name.into(),
        }
    }

    fn rendered(shell: SupportedShell, name: &str) -> String {
        // Bypass the async run() — the wrapper string is pure, the
        // print! is just a side effect we don't want in tests.
        match shell {
            SupportedShell::Powershell => powershell_wrapper(name),
            SupportedShell::Bash | SupportedShell::Zsh => posix_wrapper(name),
            SupportedShell::Fish => fish_wrapper(name),
        }
    }

    #[test]
    fn posix_wrapper_dispatches_cd_and_tui_through_sidechannel() {
        let body = rendered(SupportedShell::Bash, "shoka");
        assert!(
            body.contains("shoka()"),
            "function name should be `shoka`: {body}"
        );
        assert!(
            body.contains("SHOKA_CD_OUT"),
            "wrapper must set the sidechannel env var: {body}"
        );
        assert!(
            body.contains("cd|tui)"),
            "wrapper must intercept both `cd` and `tui`: {body}"
        );
        assert!(
            body.contains("command shoka"),
            "wrapper must use `command shoka` to bypass the function: {body}"
        );
        // The previous wrapper version captured `shoka cd`'s stdout
        // into a variable. With the sidechannel, stdout is left
        // alone (so the prompt UI renders) — guard against the old
        // shape creeping back in.
        assert!(
            !body.contains("$(command shoka cd"),
            "wrapper must not capture stdout via command substitution: {body}"
        );
    }

    #[test]
    fn posix_wrapper_avoids_zsh_readonly_status_variable() {
        // The posix wrapper is shared with zsh, where `status` is a
        // readonly special parameter (an alias for `$?`). Declaring or
        // assigning to it aborts the function with `read-only
        // variable: status`, breaking `shoka cd` under zsh (#146).
        // Guard against the reserved name creeping back in.
        let body = rendered(SupportedShell::Zsh, "shoka");
        // Match the *usage* of a `status` variable (declaration,
        // assignment, or reference) rather than the bare substring, so
        // an unrelated future comment or command name containing
        // "status" doesn't trip a false positive.
        let uses_status = body.contains("status=")
            || body.contains("$status")
            || body.lines().any(|l| {
                l.trim_start().starts_with("local ") && l.split_whitespace().any(|w| w == "status")
            });
        assert!(
            !uses_status,
            "posix wrapper must not declare, assign, or reference the zsh-readonly `status` variable: {body}"
        );
        // The exit code lives in `rc`. Order-independent so adding or
        // reordering locals doesn't break the guard.
        assert!(
            body.contains("local ") && body.contains(" rc"),
            "posix wrapper should hold the exit code in `rc`: {body}"
        );
    }

    #[test]
    fn posix_wrapper_passes_other_subcommands_through_to_binary() {
        // A non-cd, non-tui subcommand must reach the binary
        // unchanged so users can run e.g. `shoka clone` with the
        // function shadow still present. The pass-through arm is
        // the wildcard branch of the case statement.
        let body = rendered(SupportedShell::Bash, "shoka");
        let wildcard_pos = body
            .find("*)")
            .expect("wildcard pass-through branch must exist");
        let after = &body[wildcard_pos..];
        assert!(
            after.contains(r#"command shoka "$@""#),
            "pass-through arm must invoke the binary without sidechannel setup: {body}"
        );
    }

    #[test]
    fn fish_wrapper_dispatches_cd_and_tui_through_sidechannel() {
        let body = rendered(SupportedShell::Fish, "shoka");
        assert!(body.contains("function shoka"));
        assert!(body.contains("SHOKA_CD_OUT"));
        assert!(
            body.contains("case cd tui"),
            "fish wrapper must intercept both `cd` and `tui`: {body}"
        );
        assert!(
            body.contains("command shoka"),
            "fish wrapper must invoke the binary via `command`: {body}"
        );
        // fish wrappers also must not capture stdout via subshell.
        assert!(
            !body.contains("(command shoka cd"),
            "fish wrapper must not capture stdout via subshell: {body}"
        );
    }

    #[test]
    fn powershell_wrapper_dispatches_cd_and_tui_through_sidechannel() {
        let body = rendered(SupportedShell::Powershell, "shoka");
        assert!(body.contains("function shoka"));
        assert!(body.contains("SHOKA_CD_OUT"));
        // Verify the binary is resolved via `Get-Command`, not
        // re-invoked by name (which would recurse into the function).
        assert!(
            body.contains("Get-Command -Name shoka -CommandType Application"),
            "PowerShell wrapper must resolve the binary explicitly to avoid recursion: {body}"
        );
        // Verify the resolved path is cached in a script-scope
        // variable so the 50-200 ms `Get-Command` cost runs once
        // per session, not on every wrapper invocation.
        assert!(
            body.contains("$script:ShokaExe"),
            "PowerShell wrapper must cache the resolved binary in $script:ShokaExe: {body}"
        );
        assert!(
            body.contains("$first -eq 'cd' -or $first -eq 'tui'"),
            "PowerShell wrapper must dispatch on the first arg: {body}"
        );
        // try/finally is present so the tmp file + env var cleanup
        // happens even if shoka cd panics.
        assert!(body.contains("finally"), "wrapper missing cleanup: {body}");
        // PowerShell-specific: avoid the array-unwrap trap. The
        // bare-`shoka` branch must invoke `& $exe tui` *literally*
        // (not splat a single-element array, which PowerShell
        // silently unwraps to a string then iterates as characters).
        assert!(
            body.contains("& $script:ShokaExe tui"),
            "PowerShell wrapper must invoke tui literally to avoid the array-unwrap trap: {body}"
        );
    }

    #[test]
    fn posix_wrapper_defaults_to_tui_when_called_without_args() {
        // Bare `shoka` is the most common action users want — drop
        // into the dashboard. `set -- tui` rewrites the positional
        // args inside the function so the existing `cd|tui` case
        // arm picks it up without duplicating the sidechannel
        // teardown logic.
        let body = rendered(SupportedShell::Bash, "shoka");
        assert!(
            body.contains("if [ $# -eq 0 ]; then\n        set -- tui\n    fi"),
            "bash wrapper must default to `tui` when called without args: {body}"
        );
    }

    #[test]
    fn fish_wrapper_defaults_to_tui_when_called_without_args() {
        let body = rendered(SupportedShell::Fish, "shoka");
        assert!(
            body.contains("if test (count $argv) -eq 0\n        set argv tui\n    end"),
            "fish wrapper must default to `tui` when called without args: {body}"
        );
    }

    #[test]
    fn powershell_wrapper_defaults_to_tui_when_called_without_args() {
        // Two-part assertion: the `$first` default is `'tui'`, and
        // the actual subprocess invocation passes `tui` literally
        // (the array-unwrap-trap guard above covers the literal
        // form; this test catches the regression of dropping the
        // default-fallback altogether).
        let body = rendered(SupportedShell::Powershell, "shoka");
        assert!(
            body.contains("if ($args.Count -gt 0) { $args[0] } else { 'tui' }"),
            "PowerShell wrapper must compute first-arg default as `tui`: {body}"
        );
        assert!(
            body.contains("if ($args.Count -eq 0) {\n                & $script:ShokaExe tui"),
            "PowerShell wrapper must invoke shoka.exe tui when called without args: {body}"
        );
    }

    #[test]
    fn custom_name_substitutes_throughout() {
        // Users overriding `--name s` for a shorter alias must get
        // the same dispatch behavior at the new name, not a half-
        // renamed wrapper.
        let body = rendered(SupportedShell::Bash, "s");
        assert!(body.contains("s()"));
        assert!(body.contains("cd|tui)"));
        assert!(body.contains("command shoka"));
        assert!(
            !body.contains("shoka()"),
            "custom name must not also emit a default `shoka()` definition: {body}"
        );
    }

    #[test]
    fn run_emits_wrapper_for_each_supported_shell() {
        // Doesn't capture stdout (would need IO redirection), but
        // confirms the dispatcher actually picks a wrapper without
        // panicking for every supported shell. The wrapper body
        // itself is verified by the per-shell tests above.
        let rt = tokio::runtime::Runtime::new().unwrap();
        for shell in [
            SupportedShell::Bash,
            SupportedShell::Zsh,
            SupportedShell::Fish,
            SupportedShell::Powershell,
        ] {
            rt.block_on(run(args(shell, "shoka"))).unwrap();
        }
    }
}