mud-git 1.1.7__tar.gz → 1.1.8__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.
Files changed (41) hide show
  1. {mud_git-1.1.7 → mud_git-1.1.8}/AGENTS.md +13 -5
  2. {mud_git-1.1.7 → mud_git-1.1.8}/PKG-INFO +29 -10
  3. {mud_git-1.1.7 → mud_git-1.1.8}/README.md +28 -9
  4. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud/__init__.py +9 -1
  5. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud/app.py +20 -7
  6. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud/commands.py +3 -1
  7. mud_git-1.1.8/src/mud/completion.py +143 -0
  8. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud/settings.py +4 -2
  9. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud_git.egg-info/PKG-INFO +29 -10
  10. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud_git.egg-info/SOURCES.txt +2 -0
  11. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud_git.egg-info/scm_file_list.json +2 -0
  12. mud_git-1.1.8/src/mud_git.egg-info/scm_version.json +8 -0
  13. mud_git-1.1.8/tests/test_completion.py +232 -0
  14. {mud_git-1.1.7 → mud_git-1.1.8}/tests/test_filters.py +36 -0
  15. mud_git-1.1.7/src/mud_git.egg-info/scm_version.json +0 -8
  16. {mud_git-1.1.7 → mud_git-1.1.8}/.editorconfig +0 -0
  17. {mud_git-1.1.7 → mud_git-1.1.8}/.github/workflows/publish-aur.yaml +0 -0
  18. {mud_git-1.1.7 → mud_git-1.1.8}/.github/workflows/publish-pypi.yaml +0 -0
  19. {mud_git-1.1.7 → mud_git-1.1.8}/.github/workflows/test.yaml +0 -0
  20. {mud_git-1.1.7 → mud_git-1.1.8}/.gitignore +0 -0
  21. {mud_git-1.1.7 → mud_git-1.1.8}/LICENSE +0 -0
  22. {mud_git-1.1.7 → mud_git-1.1.8}/PKGBUILD +0 -0
  23. {mud_git-1.1.7 → mud_git-1.1.8}/img.png +0 -0
  24. {mud_git-1.1.7 → mud_git-1.1.8}/pyproject.toml +0 -0
  25. {mud_git-1.1.7 → mud_git-1.1.8}/requirements.txt +0 -0
  26. {mud_git-1.1.7 → mud_git-1.1.8}/setup.cfg +0 -0
  27. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud/__main__.py +0 -0
  28. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud/config.py +0 -0
  29. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud/runner.py +0 -0
  30. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud/styles.py +0 -0
  31. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud/utils.py +0 -0
  32. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud_git.egg-info/dependency_links.txt +0 -0
  33. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud_git.egg-info/entry_points.txt +0 -0
  34. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud_git.egg-info/requires.txt +0 -0
  35. {mud_git-1.1.7 → mud_git-1.1.8}/src/mud_git.egg-info/top_level.txt +0 -0
  36. {mud_git-1.1.7 → mud_git-1.1.8}/tests/conftest.py +0 -0
  37. {mud_git-1.1.7 → mud_git-1.1.8}/tests/helpers.py +0 -0
  38. {mud_git-1.1.7 → mud_git-1.1.8}/tests/test_config.py +0 -0
  39. {mud_git-1.1.7 → mud_git-1.1.8}/tests/test_display.py +0 -0
  40. {mud_git-1.1.7 → mud_git-1.1.8}/tests/test_run.py +0 -0
  41. {mud_git-1.1.7 → mud_git-1.1.8}/tests/test_states.py +0 -0
@@ -17,17 +17,18 @@ pytest tests/test_run.py # run a single file
17
17
 
18
18
  ## Entry point
19
19
 
20
- `mud` CLI → `mud:run` in `src/mud/__init__.py` → `App` in `src/mud/app.py`
20
+ `mud` CLI → `mud:run` in `src/mud/__init__.py` → `App` in `src/mud/app.py`; `mud completion` is dispatched to `completion.complete()` before writable settings are initialised.
21
21
 
22
22
  ## Module map
23
23
 
24
24
  | File | Purpose |
25
25
  |---|---|
26
- | `src/mud/__init__.py` | Entry point; initialises `Settings`, creates `App`, calls `run()` |
26
+ | `src/mud/__init__.py` | Entry point; creates `App`, dispatches completion or initialises `Settings` and calls `run()` |
27
27
  | `src/mud/app.py` | CLI dispatch; parses args, applies filters, calls `Runner` |
28
+ | `src/mud/completion.py` | Exports the Carapace spec and provides read-only dynamic completion via `C_ARG<n>` / `C_VALUE` |
28
29
  | `src/mud/runner.py` | All display commands and execution modes |
29
30
  | `src/mud/config.py` | `.mudconfig` TSV read/write; `init`, `add`, `remove`, `prune` |
30
- | `src/mud/settings.py` | `~/.config/mud/settings.ini` read/write |
31
+ | `src/mud/settings.py` | `~/.config/mud/settings.ini` read/write; read-only initialisation for completion |
31
32
  | `src/mud/commands.py` | Constants for every command name and filter flag prefix |
32
33
  | `src/mud/styles.py` | ANSI escape codes and Nerd Font glyphs |
33
34
  | `src/mud/utils.py` | Shared helpers: table creation, error printing, configure wizard |
@@ -38,9 +39,13 @@ pytest tests/test_run.py # run a single file
38
39
 
39
40
  **Command dispatch** — `App.run()` routes to either a native `Runner` method (matched against constants in `commands.py`) or a shell pass-through. The `--` separator and `-c=<cmd>` flag both reach the shell path.
40
41
 
42
+ **Carapace completion** — `mud completion carapace` exports a shell-independent spec; its fixed `mud completion values` callback handles mud's equals-only flags without executing user input. Command/flag descriptions come from the argparse definitions, aliases from settings, and label/path/branch values from the discovered config. Local and remote branch names reuse `Runner._get_unique_branch_names()`. Nothing is completed after `--`, inside `-c=`, or in arbitrary shell/alias arguments. Filters remain available after a completed `-c=` token. Neither spec export nor completion creates settings files.
43
+
44
+ **Argument metadata** — argparse registers value-taking options without trailing `=` and toggles as booleans, so empty values and `-t` cannot consume native commands. The execution scanner still requires `-flag=value`; completion inserts the equals sign.
45
+
41
46
  **Execution modes** — three modes controlled by `run_async` + `run_table` settings (toggled by `-a` / `-t` flags): sequential, async streamed, async live-table.
42
47
 
43
- **Filter chain** — `App._filter_with_arguments()` applies up to eight filters in sequence (ignore label, include/exclude label, include/exclude branch, name substring, modified, diverged). Each step removes non-matching repos.
48
+ **Filter chain** — `App._filter_with_arguments()` applies up to nine filters in sequence (ignore label, include/exclude label, include/exclude branch, include/exclude name substring, modified, diverged). Each step removes non-matching repos. Repeated `-N=` / `--not-name=` values exclude any matching path substring; empty exclusions are ignored.
44
49
 
45
50
  **Nerd Fonts** — every glyph in `styles.GLYPHS` has an ASCII fallback. `utils.glyphs(key)` selects between them based on the `nerd_fonts` setting, so mud works with or without a patched font.
46
51
 
@@ -53,9 +58,12 @@ Tests are black-box CLI tests — each runs `python -m mud` as a subprocess agai
53
58
  | `tests/test_config.py` | `init`, `add`, `remove`, `prune` |
54
59
  | `tests/test_display.py` | `status`, `info`, `log`, `labels`, `branches`, `tags` |
55
60
  | `tests/test_run.py` | Execution modes and flags |
56
- | `tests/test_filters.py` | `-l=`, `-L=`, `-b=`, `-B=`, `-n=` filter flags |
61
+ | `tests/test_filters.py` | `-l=`, `-L=`, `-b=`, `-B=`, `-n=`, `-N=` filter flags and native-command parsing regressions |
62
+ | `tests/test_completion.py` | Completion callback, dynamic values, command boundaries, read-only discovery, and optional real Carapace/Nushell integration |
57
63
  | `tests/test_states.py` | Edge-case repo states (unborn, detached, rebasing) |
58
64
 
65
+ Carapace integration tests run when `carapace` is on `PATH`; the Nushell round-trip test also requires `nu`. Tests isolate `HOME`, Carapace config/cache directories, and use the current Python environment's `mud` executable.
66
+
59
67
  ## Knowledge base update rule
60
68
  After editing any file under `src/mud/`, update the affected sections of this file before finishing the task.
61
69
  `README.md` is the source of truth for user-facing behaviour — if commands, flags, or settings changed, update the relevant table rows there. Table edits only — no new prose.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mud-git
3
- Version: 1.1.7
3
+ Version: 1.1.8
4
4
  Summary: Multi repository git utility. Manage multiple git-repositories simultaneously.
5
5
  Author-email: Jasur Sadikov <jasur@sadikoff.com>
6
6
  License-Expression: MIT
@@ -65,18 +65,23 @@ mud --diverged git pull
65
65
  ### Commands
66
66
  | Command | Description |
67
67
  |---------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
68
- | `mud set-global` | sets the current `.mudconfig` as a global configuration so it will be used as a fallback configuration to run from any directory. |
68
+ | `mud init` | creates `.mudconfig` and adds repositories beneath the current directory. |
69
+ | `mud configure`/`mud config` | runs the interactive settings wizard. |
70
+ | `mud help`/`mud --help`/`mud -h` | displays available commands and flags. |
71
+ | `mud set-global [path]` | sets the current `.mudconfig`, or the specified configuration path, as the fallback configuration to run from any directory. |
69
72
  | `mud get-config` | prints the current `.mudconfig` location. |
70
73
  | `mud prune` | removes all invalid repositories from the `.mudconfig`. |
71
74
  | `mud info`/`mud i` | displays branch divergence and working directory changes. |
72
75
  | `mud status`/`mud st` | displays working directory changes. |
73
76
  | `mud log`/`mud l` | displays the latest commit message, its time, and its author. |
74
77
  | `mud labels`/`mud lb` | displays mud labels across repositories. |
75
- | `mud branches`/`mud br` | displays all branches in repositories. |
76
- | `mud remote-branches`/`mud rbr` | displays all remote branches in repositories. |
78
+ | `mud branch`/`mud branches`/`mud br` | displays all branches in repositories. |
79
+ | `mud remote-branch`/`mud remote-branches`/`mud rbr` | displays all remote branches in repositories. |
77
80
  | `mud complete-branch` | prints unique current branch names across repositories for shell completion. |
78
81
  | `mud complete-branch-all` | prints unique local and remote branch names across repositories for shell completion. |
79
- | `mud tags`/`mud t` | displays git tags in repositories. |
82
+ | `mud completion carapace` | exports a Carapace spec for all commands, aliases, flags, and native arguments; dynamically completes labels, local/remote branch names, and configured repository paths. |
83
+ | `mud completion values` | internal read-only Carapace callback; reads completion context from `C_ARG<n>` and `C_VALUE`, never executing command text. |
84
+ | `mud tags`/`mud tag`/`mud t` | displays git tags in repositories. |
80
85
 
81
86
  `--` format is also supported. An example would be `mud -- git status`.
82
87
 
@@ -87,11 +92,13 @@ mud --diverged git pull
87
92
  | Flag | Description |
88
93
  |------------------------------------------|--------------------------------------------------------------------------------------|
89
94
  | `-n=<str>` or `--name=<str>` | includes repositories that contains provided string. |
95
+ | `-N=<str>` or `--not-name=<str>` | excludes repositories whose path contains the provided string; repeat to exclude multiple substrings. |
90
96
  | `-l=<label>` or `--label=<label>` | includes repositories with the provided label. |
91
97
  | `-L=<label>` or `--not-label=<label>` | excludes repositories with the provided label. |
92
98
  | `-b=<branch>` or `--branch=<branch>` | includes repositories with the provided branch. |
93
99
  | `-B=<branch>` or `--not-branch=<branch>` | excludes repositories with the provided branch. |
94
- | `-c` or `--command` | explicit command argument. Use this whenever you're trying to run a complex command. |
100
+ | `-c="<command>"` or `--command="<command>"` | explicit shell command; no completion inside its value, but mud filters remain available in following arguments. |
101
+ | `--` | starts an opaque shell command; no mud completion or filtering after this separator. |
95
102
  | `-m` or `--modified` | filters out modified repositories. |
96
103
  | `-d` or `--diverged` | filters repositories with diverged branches. |
97
104
  | `-t` or `--table` | toggles the default table view setting for execution. |
@@ -117,6 +124,18 @@ mud complete-branch
117
124
  mud complete-branch-all
118
125
  ```
119
126
 
127
+ | Carapace Setup | Instructions |
128
+ |----------------|--------------|
129
+ | Requirements | Install `mud` and [Carapace](https://carapace-sh.github.io/carapace-bin/install.html) on `PATH`; the same spec works across Carapace-supported shells. |
130
+ | Install Spec (Nushell, Linux) | `let specs = (($env.XDG_CONFIG_HOME? \| default ($nu.home-path \| path join .config)) \| path join carapace specs)`; `mkdir $specs`; `mud completion carapace \| save --force ($specs \| path join mud.yaml)` |
131
+ | Install Spec (Bash/Zsh, Linux) | `mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/carapace/specs"`; `mud completion carapace > "${XDG_CONFIG_HOME:-$HOME/.config}/carapace/specs/mud.yaml"` |
132
+ | Nushell Hook | Follow [Carapace's Nushell setup](https://carapace-sh.github.io/carapace-bin/setup.html#nushell), or use `{\|spans\| carapace $spans.0 nushell ...$spans \| from json }` as your external completer; preserve empty results for mud rather than falling back to file or command completion. |
133
+ | Bash/Zsh Hook | `source <(carapace _carapace)`; Zsh also requires `autoload -U compinit && compinit`. |
134
+ | Fish Hook | `carapace _carapace fish \| source` |
135
+ | Other Platforms/Shells | Install `mud.yaml` in [Carapace's user spec directory](https://carapace-sh.github.io/carapace-bin/spec/user.html), then follow the appropriate [shell setup](https://carapace-sh.github.io/carapace-bin/setup.html). Restart the shell after first installing the spec. |
136
+ | Dynamic Values | Labels and paths come from the nearest ancestor `.mudconfig` or global fallback; branches include unique local and remote names with remote prefixes removed. Branch filters still match the current branch. No settings or repositories are written during completion. |
137
+ | Command Boundaries | Complete mud filters before a command or `--`, or after a completed `-c="..."` argument. Arbitrary shell commands and alias arguments are not completed. |
138
+
120
139
  ## Settings
121
140
 
122
141
  Settings are stored at `~/.config/mud/settings.ini`.
@@ -150,7 +169,7 @@ You can modify your `.mudconfig` file using the following commands:
150
169
 
151
170
  | Command | Description |
152
171
  |-----------------------------|------------------------------------------------|
153
- | `mud add <path>` | adds a path without a label. |
154
- | `mud add <path> <label>` | adds a path with an optional label. |
155
- | `mud remove <path>` | removes the directory with the specified path. |
156
- | `mud remove <path> <label>` | removes the label from a directory. |
172
+ | `mud add <path>`/`mud a <path>` | adds a path without a label. |
173
+ | `mud add <path> <label>`/`mud a <path> <label>` | adds a path with an optional label. |
174
+ | `mud remove <path>`/`mud rm <path>` | removes the directory with the specified path. |
175
+ | `mud remove <path> <label>`/`mud rm <path> <label>` | removes the label from a directory. |
@@ -48,18 +48,23 @@ mud --diverged git pull
48
48
  ### Commands
49
49
  | Command | Description |
50
50
  |---------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
51
- | `mud set-global` | sets the current `.mudconfig` as a global configuration so it will be used as a fallback configuration to run from any directory. |
51
+ | `mud init` | creates `.mudconfig` and adds repositories beneath the current directory. |
52
+ | `mud configure`/`mud config` | runs the interactive settings wizard. |
53
+ | `mud help`/`mud --help`/`mud -h` | displays available commands and flags. |
54
+ | `mud set-global [path]` | sets the current `.mudconfig`, or the specified configuration path, as the fallback configuration to run from any directory. |
52
55
  | `mud get-config` | prints the current `.mudconfig` location. |
53
56
  | `mud prune` | removes all invalid repositories from the `.mudconfig`. |
54
57
  | `mud info`/`mud i` | displays branch divergence and working directory changes. |
55
58
  | `mud status`/`mud st` | displays working directory changes. |
56
59
  | `mud log`/`mud l` | displays the latest commit message, its time, and its author. |
57
60
  | `mud labels`/`mud lb` | displays mud labels across repositories. |
58
- | `mud branches`/`mud br` | displays all branches in repositories. |
59
- | `mud remote-branches`/`mud rbr` | displays all remote branches in repositories. |
61
+ | `mud branch`/`mud branches`/`mud br` | displays all branches in repositories. |
62
+ | `mud remote-branch`/`mud remote-branches`/`mud rbr` | displays all remote branches in repositories. |
60
63
  | `mud complete-branch` | prints unique current branch names across repositories for shell completion. |
61
64
  | `mud complete-branch-all` | prints unique local and remote branch names across repositories for shell completion. |
62
- | `mud tags`/`mud t` | displays git tags in repositories. |
65
+ | `mud completion carapace` | exports a Carapace spec for all commands, aliases, flags, and native arguments; dynamically completes labels, local/remote branch names, and configured repository paths. |
66
+ | `mud completion values` | internal read-only Carapace callback; reads completion context from `C_ARG<n>` and `C_VALUE`, never executing command text. |
67
+ | `mud tags`/`mud tag`/`mud t` | displays git tags in repositories. |
63
68
 
64
69
  `--` format is also supported. An example would be `mud -- git status`.
65
70
 
@@ -70,11 +75,13 @@ mud --diverged git pull
70
75
  | Flag | Description |
71
76
  |------------------------------------------|--------------------------------------------------------------------------------------|
72
77
  | `-n=<str>` or `--name=<str>` | includes repositories that contains provided string. |
78
+ | `-N=<str>` or `--not-name=<str>` | excludes repositories whose path contains the provided string; repeat to exclude multiple substrings. |
73
79
  | `-l=<label>` or `--label=<label>` | includes repositories with the provided label. |
74
80
  | `-L=<label>` or `--not-label=<label>` | excludes repositories with the provided label. |
75
81
  | `-b=<branch>` or `--branch=<branch>` | includes repositories with the provided branch. |
76
82
  | `-B=<branch>` or `--not-branch=<branch>` | excludes repositories with the provided branch. |
77
- | `-c` or `--command` | explicit command argument. Use this whenever you're trying to run a complex command. |
83
+ | `-c="<command>"` or `--command="<command>"` | explicit shell command; no completion inside its value, but mud filters remain available in following arguments. |
84
+ | `--` | starts an opaque shell command; no mud completion or filtering after this separator. |
78
85
  | `-m` or `--modified` | filters out modified repositories. |
79
86
  | `-d` or `--diverged` | filters repositories with diverged branches. |
80
87
  | `-t` or `--table` | toggles the default table view setting for execution. |
@@ -100,6 +107,18 @@ mud complete-branch
100
107
  mud complete-branch-all
101
108
  ```
102
109
 
110
+ | Carapace Setup | Instructions |
111
+ |----------------|--------------|
112
+ | Requirements | Install `mud` and [Carapace](https://carapace-sh.github.io/carapace-bin/install.html) on `PATH`; the same spec works across Carapace-supported shells. |
113
+ | Install Spec (Nushell, Linux) | `let specs = (($env.XDG_CONFIG_HOME? \| default ($nu.home-path \| path join .config)) \| path join carapace specs)`; `mkdir $specs`; `mud completion carapace \| save --force ($specs \| path join mud.yaml)` |
114
+ | Install Spec (Bash/Zsh, Linux) | `mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/carapace/specs"`; `mud completion carapace > "${XDG_CONFIG_HOME:-$HOME/.config}/carapace/specs/mud.yaml"` |
115
+ | Nushell Hook | Follow [Carapace's Nushell setup](https://carapace-sh.github.io/carapace-bin/setup.html#nushell), or use `{\|spans\| carapace $spans.0 nushell ...$spans \| from json }` as your external completer; preserve empty results for mud rather than falling back to file or command completion. |
116
+ | Bash/Zsh Hook | `source <(carapace _carapace)`; Zsh also requires `autoload -U compinit && compinit`. |
117
+ | Fish Hook | `carapace _carapace fish \| source` |
118
+ | Other Platforms/Shells | Install `mud.yaml` in [Carapace's user spec directory](https://carapace-sh.github.io/carapace-bin/spec/user.html), then follow the appropriate [shell setup](https://carapace-sh.github.io/carapace-bin/setup.html). Restart the shell after first installing the spec. |
119
+ | Dynamic Values | Labels and paths come from the nearest ancestor `.mudconfig` or global fallback; branches include unique local and remote names with remote prefixes removed. Branch filters still match the current branch. No settings or repositories are written during completion. |
120
+ | Command Boundaries | Complete mud filters before a command or `--`, or after a completed `-c="..."` argument. Arbitrary shell commands and alias arguments are not completed. |
121
+
103
122
  ## Settings
104
123
 
105
124
  Settings are stored at `~/.config/mud/settings.ini`.
@@ -133,7 +152,7 @@ You can modify your `.mudconfig` file using the following commands:
133
152
 
134
153
  | Command | Description |
135
154
  |-----------------------------|------------------------------------------------|
136
- | `mud add <path>` | adds a path without a label. |
137
- | `mud add <path> <label>` | adds a path with an optional label. |
138
- | `mud remove <path>` | removes the directory with the specified path. |
139
- | `mud remove <path> <label>` | removes the label from a directory. |
155
+ | `mud add <path>`/`mud a <path>` | adds a path without a label. |
156
+ | `mud add <path> <label>`/`mud a <path> <label>` | adds a path with an optional label. |
157
+ | `mud remove <path>`/`mud rm <path>` | removes the directory with the specified path. |
158
+ | `mud remove <path> <label>`/`mud rm <path> <label>` | removes the label from a directory. |
@@ -2,15 +2,23 @@
2
2
 
3
3
  import mud.utils
4
4
  import mud.settings
5
+ import sys
5
6
 
6
7
  from mud.app import App
8
+ from mud.commands import COMPLETION
7
9
 
8
10
 
9
11
  def run():
10
12
  try:
13
+ app: App = App()
14
+ if sys.argv[1:2] == COMPLETION:
15
+ from mud.completion import complete
16
+
17
+ complete(app.parser)
18
+ return
19
+
11
20
  utils.settings = settings.Settings(utils.SETTINGS_FILE_NAME, utils.OLD_SETTINGS_FILE_NAME)
12
21
 
13
- app: App = App()
14
22
  app.run()
15
23
  except KeyboardInterrupt:
16
24
  utils.print_error(0)
@@ -51,6 +51,8 @@ class App:
51
51
  subparsers.add_parser(REMOTE_BRANCHES[0], aliases=REMOTE_BRANCHES[1:], help='Displays all remote branches in repositories.')
52
52
  subparsers.add_parser(COMPLETE_BRANCH[0], help='Prints unique current branch names across repositories for shell completion.')
53
53
  subparsers.add_parser(COMPLETE_BRANCH_ALL[0], help='Prints unique local and remote branch names across repositories for shell completion.')
54
+ completion_parser = subparsers.add_parser(COMPLETION[0], help='Prints shell completion support.')
55
+ completion_parser.add_argument('shell', choices=['carapace', 'values'], help='Completion format (values is the Carapace callback).')
54
56
  subparsers.add_parser(CONFIGURE[0], aliases=CONFIGURE[1:], help='Runs the interactive configuration wizard.')
55
57
  subparsers.add_parser(GET_CONFIG[0], aliases=GET_CONFIG[1:], help='Prints current .mudconfig path.')
56
58
  subparsers.add_parser(SET_GLOBAL[0], aliases=SET_GLOBAL[1:], help='Sets .mudconfig in the current repository as your fallback .mudconfig.')
@@ -65,13 +67,17 @@ class App:
65
67
 
66
68
  subparsers.add_parser(PRUNE[0], help='Removes invalid paths from .mudconfig.')
67
69
 
68
- parser.add_argument(*COMMAND_ATTR, metavar='COMMAND', help=f'Explicit command argument. Use this when you want to run a command that has a special characters.', nargs='?', default='', type=str)
69
- parser.add_argument(*TABLE_ATTR, metavar='TABLE', help=f'Switches table view, runs in table view it is disabled in .mudsettings.', nargs='?', default='', type=str)
70
- parser.add_argument(*NAME_PREFIX, metavar='NAME', help='Includes repositories where name contains provided value.', nargs='?', default='', type=str)
71
- parser.add_argument(*LABEL_PREFIX, metavar='LABEL', help='Includes repositories with provided label.', nargs='?', default='', type=str)
72
- parser.add_argument(*NOT_LABEL_PREFIX, metavar='NOT_LABEL', help=f'Excludes repositories with provided label.', nargs='?', default='', type=str)
73
- parser.add_argument(*BRANCH_PREFIX, metavar='BRANCH', help='Includes repositories on a provided branch.', nargs='?', default='', type=str)
74
- parser.add_argument(*NOT_BRANCH_PREFIX, metavar='NOT_BRANCH', help='Excludes repositories on a provided branch.', nargs='?', default='', type=str)
70
+ for prefixes, metavar, description in [
71
+ (COMMAND_ATTR, 'COMMAND', 'Explicit shell command; use for complex commands.'),
72
+ (NAME_PREFIX, 'NAME', 'Includes repositories where name contains provided value.'),
73
+ (NOT_NAME_PREFIX, 'NOT_NAME', 'Excludes repositories where name contains provided value.'),
74
+ (LABEL_PREFIX, 'LABEL', 'Includes repositories with provided label.'),
75
+ (NOT_LABEL_PREFIX, 'NOT_LABEL', 'Excludes repositories with provided label.'),
76
+ (BRANCH_PREFIX, 'BRANCH', 'Includes repositories on a provided branch.'),
77
+ (NOT_BRANCH_PREFIX, 'NOT_BRANCH', 'Excludes repositories on a provided branch.'),
78
+ ]:
79
+ parser.add_argument(*(prefix.rstrip('=') for prefix in prefixes), metavar=metavar, help=description, default='', type=str)
80
+ parser.add_argument(*TABLE_ATTR, action='store_true', help='Toggles the table view setting for execution.')
75
81
  parser.add_argument(*MODIFIED_ATTR, action='store_true', help='Filters modified repositories.')
76
82
  parser.add_argument(*DIVERGED_ATTR, action='store_true', help='Filters repositories with diverged branches.')
77
83
  parser.add_argument(*ASYNC_ATTR, action='store_true', help='Switches asynchronous run feature.')
@@ -229,6 +235,7 @@ class App:
229
235
  include_labels = []
230
236
  exclude_labels = []
231
237
  contains_strings = []
238
+ not_contains_strings = []
232
239
  include_branches = []
233
240
  exclude_branches = []
234
241
  modified = False
@@ -253,6 +260,10 @@ class App:
253
260
  exclude_branches.append(arg.split('=', 1)[1])
254
261
  elif any(arg.startswith(prefix) for prefix in NAME_PREFIX):
255
262
  contains_strings.append(arg.split('=', 1)[1])
263
+ elif any(arg.startswith(prefix) for prefix in NOT_NAME_PREFIX):
264
+ value = arg.split('=', 1)[1]
265
+ if value:
266
+ not_contains_strings.append(value)
256
267
  elif arg in MODIFIED_ATTR:
257
268
  modified = True
258
269
  elif arg in DIVERGED_ATTR:
@@ -297,6 +308,8 @@ class App:
297
308
  delete = True
298
309
  if any(contains_strings) and not any(substr in path for substr in contains_strings):
299
310
  delete = True
311
+ if any(not_contains_strings) and any(substr in path for substr in not_contains_strings):
312
+ delete = True
300
313
 
301
314
  if not delete and not repo.head_is_unborn and (any(include_branches) or any(exclude_branches)):
302
315
  if any(include_branches) and repo.head.shorthand not in include_branches:
@@ -12,12 +12,13 @@ BRANCHES = ['branch', 'branches', 'br']
12
12
  REMOTE_BRANCHES = ['remote-branch', 'remote-branches', 'rbr']
13
13
  COMPLETE_BRANCH = ['complete-branch']
14
14
  COMPLETE_BRANCH_ALL = ['complete-branch-all']
15
+ COMPLETION = ['completion']
15
16
  HELP = ['help', '--help', '-h']
16
17
  CONFIGURE = ['configure', 'config']
17
18
  GET_CONFIG = ['get-config']
18
19
  SET_GLOBAL = ['set-global']
19
20
 
20
- COMMANDS = ADD + REMOVE + PRUNE + LOG + INFO + INIT + TAGS + LABELS + STATUS + BRANCHES + REMOTE_BRANCHES + COMPLETE_BRANCH + COMPLETE_BRANCH_ALL + HELP + CONFIGURE + SET_GLOBAL + GET_CONFIG
21
+ COMMANDS = ADD + REMOVE + PRUNE + LOG + INFO + INIT + TAGS + LABELS + STATUS + BRANCHES + REMOTE_BRANCHES + COMPLETE_BRANCH + COMPLETE_BRANCH_ALL + COMPLETION + HELP + CONFIGURE + SET_GLOBAL + GET_CONFIG
21
22
 
22
23
  # Filters
23
24
  ASYNC_ATTR = '-a', '--async'
@@ -25,6 +26,7 @@ TABLE_ATTR = '-t', '--table'
25
26
  MODIFIED_ATTR = '-m', '--modified'
26
27
  DIVERGED_ATTR = '-d', '--diverged'
27
28
  NAME_PREFIX = '-n=', '--name='
29
+ NOT_NAME_PREFIX = '-N=', '--not-name='
28
30
  LABEL_PREFIX = '-l=', '--label='
29
31
  COMMAND_ATTR = '-c=', '--command='
30
32
  BRANCH_PREFIX = '-b=', '--branch='
@@ -0,0 +1,143 @@
1
+ import configparser
2
+ import io
3
+ import os
4
+ from argparse import ArgumentParser, _SubParsersAction
5
+ from contextlib import redirect_stdout
6
+ from pathlib import Path
7
+
8
+ from pygit2 import GitError
9
+
10
+ from mud import utils
11
+ from mud.commands import (
12
+ ADD, REMOVE, SET_GLOBAL, CONFIGURE, COMPLETION, HELP, COMMAND_ATTR,
13
+ LABEL_PREFIX, NOT_LABEL_PREFIX, BRANCH_PREFIX, NOT_BRANCH_PREFIX,
14
+ NAME_PREFIX, NOT_NAME_PREFIX,
15
+ )
16
+ from mud.config import Config
17
+ from mud.runner import Runner
18
+ from mud.settings import Settings
19
+
20
+
21
+ def complete(parser: ArgumentParser) -> None:
22
+ args = parser.parse_args()
23
+ if args.shell == 'carapace':
24
+ print('''name: mud
25
+ description: Run commands across multiple repositories
26
+ parsing: disabled
27
+ completion:
28
+ positionalany:
29
+ - "$(mud completion values)"
30
+ - "$nospace(=/)"
31
+ dashany: []''')
32
+ return
33
+
34
+ arguments = []
35
+ while f'C_ARG{len(arguments)}' in os.environ:
36
+ arguments.append(os.environ[f'C_ARG{len(arguments)}'])
37
+ value = os.environ.get('C_VALUE', '')
38
+ try:
39
+ utils.settings = Settings(utils.SETTINGS_FILE_NAME, utils.OLD_SETTINGS_FILE_NAME, read_only=True)
40
+ candidates = _candidates(parser, arguments, value)
41
+ except (OSError, ValueError, configparser.Error, GitError):
42
+ return
43
+
44
+ for candidate, description in sorted(set(candidates), key=lambda item: (item[0].casefold(), item[0])):
45
+ if candidate.startswith(value) and candidate.isprintable():
46
+ description = ''.join(char if char.isprintable() else ' ' for char in description)
47
+ print(f'{candidate}\t{description}')
48
+
49
+
50
+ def _repositories() -> dict[str, list[str]]:
51
+ config = Config()
52
+ directory, _ = config.find()
53
+ if not directory:
54
+ return {}
55
+
56
+ previous_directory = os.getcwd()
57
+ try:
58
+ os.chdir(directory)
59
+ # Missing paths must not turn diagnostics into completion candidates.
60
+ with redirect_stdout(io.StringIO()):
61
+ config.load(os.path.join(directory, utils.CONFIG_FILE_NAME))
62
+ except (OSError, ValueError, IndexError):
63
+ return {}
64
+ finally:
65
+ os.chdir(previous_directory)
66
+ return config.data
67
+
68
+
69
+ def _candidates(parser: ArgumentParser, arguments: list[str], value: str) -> list[tuple[str, str]]:
70
+ if '--' in arguments:
71
+ return []
72
+ subparsers = next(action.choices for action in parser._actions if isinstance(action, _SubParsersAction))
73
+ explicit_command = False
74
+ for index, argument in enumerate(arguments):
75
+ if argument.startswith(COMMAND_ATTR):
76
+ explicit_command = True
77
+ if not argument.startswith('-'):
78
+ if explicit_command:
79
+ return []
80
+ positionals = arguments[index:]
81
+ if argument in SET_GLOBAL + CONFIGURE + COMPLETION + HELP and index != 0:
82
+ return []
83
+ if value.startswith('-') and argument in subparsers and argument not in SET_GLOBAL + CONFIGURE:
84
+ return [
85
+ (flag, action.help or '') for action in subparsers[argument]._actions
86
+ for flag in action.option_strings
87
+ ]
88
+ if argument in COMPLETION and len(positionals) == 1:
89
+ return [('carapace', 'Export the Carapace spec')]
90
+ if argument in ADD + REMOVE and len(positionals) == 2:
91
+ return [(label, 'Label') for labels in _repositories().values() for label in labels]
92
+ if argument in REMOVE and len(positionals) == 1:
93
+ return [(path, 'Repository') for path in _repositories()]
94
+ if argument in ADD + SET_GLOBAL and len(positionals) == 1:
95
+ parent = os.path.dirname(value)
96
+ directory = Path(parent or '.').expanduser()
97
+ if argument in ADD:
98
+ directory = Path(Config.find()[0]) / directory
99
+ return [
100
+ (os.path.join(parent, path.name) + ('/' if path.is_dir() else ''), '')
101
+ for path in directory.iterdir()
102
+ if argument in SET_GLOBAL or path.is_dir()
103
+ ]
104
+ return []
105
+
106
+ if value.startswith(COMMAND_ATTR):
107
+ return []
108
+ if value.startswith(LABEL_PREFIX + NOT_LABEL_PREFIX + NAME_PREFIX + NOT_NAME_PREFIX):
109
+ prefix = value.split('=', 1)[0] + '='
110
+ repos = _repositories()
111
+ if value.startswith(LABEL_PREFIX + NOT_LABEL_PREFIX):
112
+ return [(prefix + label, 'Label') for labels in repos.values() for label in labels]
113
+ return [(prefix + path, 'Repository') for path in repos]
114
+ if value.startswith(BRANCH_PREFIX + NOT_BRANCH_PREFIX):
115
+ prefix = value.split('=', 1)[0] + '='
116
+ directory, _ = Config.find()
117
+ branches = []
118
+ for path in _repositories():
119
+ path = os.path.join(directory, path)
120
+ if not os.path.isdir(os.path.join(path, '.git')):
121
+ continue
122
+ try:
123
+ branches.extend(Runner._get_unique_branch_names([path], include_remote=True))
124
+ except (OSError, ValueError, GitError):
125
+ continue
126
+ return [(prefix + branch, 'Branch') for branch in branches]
127
+
128
+ candidates = []
129
+ for action in parser._actions:
130
+ candidates.extend((flag + ('=' if action.nargs != 0 else ''), action.help or '') for flag in action.option_strings)
131
+ if isinstance(action, _SubParsersAction) and not explicit_command and not value.startswith('-'):
132
+ for command in action._choices_actions:
133
+ candidates.extend(
134
+ (name, command.help or '') for name, subparser in action.choices.items()
135
+ if subparser is action.choices[command.dest]
136
+ and not (arguments and name in SET_GLOBAL + CONFIGURE + COMPLETION)
137
+ )
138
+ if not explicit_command and not value.startswith('-'):
139
+ if not arguments:
140
+ candidates.append((HELP[0], 'Show help'))
141
+ candidates.extend((alias, command) for alias, command in (utils.settings.alias_settings or {}).items())
142
+ candidates.append(('--', 'Run an opaque shell command'))
143
+ return candidates
@@ -6,7 +6,7 @@ ALIAS_SCOPE = 'alias'
6
6
 
7
7
 
8
8
  class Settings:
9
- def __init__(self, file_name: str, old_file_name: str) -> None:
9
+ def __init__(self, file_name: str, old_file_name: str, read_only: bool = False) -> None:
10
10
  use_old = os.path.exists(os.path.join(os.path.expanduser('~'), old_file_name))
11
11
  file_name = old_file_name if use_old else file_name
12
12
  directory = os.path.expanduser('~' if use_old else '~/.config/mud')
@@ -15,6 +15,7 @@ class Settings:
15
15
  self.alias_settings = None
16
16
  self.config = configparser.ConfigParser()
17
17
  self.settings_file = os.path.join(directory, file_name)
18
+ self.read_only = read_only
18
19
  self.defaults = {
19
20
  'mud': {
20
21
  'config_path': '',
@@ -38,7 +39,8 @@ class Settings:
38
39
  def load_settings(self) -> None:
39
40
  if not os.path.exists(self.settings_file):
40
41
  self.config.read_dict(self.defaults)
41
- self.save()
42
+ if not self.read_only:
43
+ self.save()
42
44
  else:
43
45
  self.config.read(self.settings_file)
44
46
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mud-git
3
- Version: 1.1.7
3
+ Version: 1.1.8
4
4
  Summary: Multi repository git utility. Manage multiple git-repositories simultaneously.
5
5
  Author-email: Jasur Sadikov <jasur@sadikoff.com>
6
6
  License-Expression: MIT
@@ -65,18 +65,23 @@ mud --diverged git pull
65
65
  ### Commands
66
66
  | Command | Description |
67
67
  |---------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
68
- | `mud set-global` | sets the current `.mudconfig` as a global configuration so it will be used as a fallback configuration to run from any directory. |
68
+ | `mud init` | creates `.mudconfig` and adds repositories beneath the current directory. |
69
+ | `mud configure`/`mud config` | runs the interactive settings wizard. |
70
+ | `mud help`/`mud --help`/`mud -h` | displays available commands and flags. |
71
+ | `mud set-global [path]` | sets the current `.mudconfig`, or the specified configuration path, as the fallback configuration to run from any directory. |
69
72
  | `mud get-config` | prints the current `.mudconfig` location. |
70
73
  | `mud prune` | removes all invalid repositories from the `.mudconfig`. |
71
74
  | `mud info`/`mud i` | displays branch divergence and working directory changes. |
72
75
  | `mud status`/`mud st` | displays working directory changes. |
73
76
  | `mud log`/`mud l` | displays the latest commit message, its time, and its author. |
74
77
  | `mud labels`/`mud lb` | displays mud labels across repositories. |
75
- | `mud branches`/`mud br` | displays all branches in repositories. |
76
- | `mud remote-branches`/`mud rbr` | displays all remote branches in repositories. |
78
+ | `mud branch`/`mud branches`/`mud br` | displays all branches in repositories. |
79
+ | `mud remote-branch`/`mud remote-branches`/`mud rbr` | displays all remote branches in repositories. |
77
80
  | `mud complete-branch` | prints unique current branch names across repositories for shell completion. |
78
81
  | `mud complete-branch-all` | prints unique local and remote branch names across repositories for shell completion. |
79
- | `mud tags`/`mud t` | displays git tags in repositories. |
82
+ | `mud completion carapace` | exports a Carapace spec for all commands, aliases, flags, and native arguments; dynamically completes labels, local/remote branch names, and configured repository paths. |
83
+ | `mud completion values` | internal read-only Carapace callback; reads completion context from `C_ARG<n>` and `C_VALUE`, never executing command text. |
84
+ | `mud tags`/`mud tag`/`mud t` | displays git tags in repositories. |
80
85
 
81
86
  `--` format is also supported. An example would be `mud -- git status`.
82
87
 
@@ -87,11 +92,13 @@ mud --diverged git pull
87
92
  | Flag | Description |
88
93
  |------------------------------------------|--------------------------------------------------------------------------------------|
89
94
  | `-n=<str>` or `--name=<str>` | includes repositories that contains provided string. |
95
+ | `-N=<str>` or `--not-name=<str>` | excludes repositories whose path contains the provided string; repeat to exclude multiple substrings. |
90
96
  | `-l=<label>` or `--label=<label>` | includes repositories with the provided label. |
91
97
  | `-L=<label>` or `--not-label=<label>` | excludes repositories with the provided label. |
92
98
  | `-b=<branch>` or `--branch=<branch>` | includes repositories with the provided branch. |
93
99
  | `-B=<branch>` or `--not-branch=<branch>` | excludes repositories with the provided branch. |
94
- | `-c` or `--command` | explicit command argument. Use this whenever you're trying to run a complex command. |
100
+ | `-c="<command>"` or `--command="<command>"` | explicit shell command; no completion inside its value, but mud filters remain available in following arguments. |
101
+ | `--` | starts an opaque shell command; no mud completion or filtering after this separator. |
95
102
  | `-m` or `--modified` | filters out modified repositories. |
96
103
  | `-d` or `--diverged` | filters repositories with diverged branches. |
97
104
  | `-t` or `--table` | toggles the default table view setting for execution. |
@@ -117,6 +124,18 @@ mud complete-branch
117
124
  mud complete-branch-all
118
125
  ```
119
126
 
127
+ | Carapace Setup | Instructions |
128
+ |----------------|--------------|
129
+ | Requirements | Install `mud` and [Carapace](https://carapace-sh.github.io/carapace-bin/install.html) on `PATH`; the same spec works across Carapace-supported shells. |
130
+ | Install Spec (Nushell, Linux) | `let specs = (($env.XDG_CONFIG_HOME? \| default ($nu.home-path \| path join .config)) \| path join carapace specs)`; `mkdir $specs`; `mud completion carapace \| save --force ($specs \| path join mud.yaml)` |
131
+ | Install Spec (Bash/Zsh, Linux) | `mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/carapace/specs"`; `mud completion carapace > "${XDG_CONFIG_HOME:-$HOME/.config}/carapace/specs/mud.yaml"` |
132
+ | Nushell Hook | Follow [Carapace's Nushell setup](https://carapace-sh.github.io/carapace-bin/setup.html#nushell), or use `{\|spans\| carapace $spans.0 nushell ...$spans \| from json }` as your external completer; preserve empty results for mud rather than falling back to file or command completion. |
133
+ | Bash/Zsh Hook | `source <(carapace _carapace)`; Zsh also requires `autoload -U compinit && compinit`. |
134
+ | Fish Hook | `carapace _carapace fish \| source` |
135
+ | Other Platforms/Shells | Install `mud.yaml` in [Carapace's user spec directory](https://carapace-sh.github.io/carapace-bin/spec/user.html), then follow the appropriate [shell setup](https://carapace-sh.github.io/carapace-bin/setup.html). Restart the shell after first installing the spec. |
136
+ | Dynamic Values | Labels and paths come from the nearest ancestor `.mudconfig` or global fallback; branches include unique local and remote names with remote prefixes removed. Branch filters still match the current branch. No settings or repositories are written during completion. |
137
+ | Command Boundaries | Complete mud filters before a command or `--`, or after a completed `-c="..."` argument. Arbitrary shell commands and alias arguments are not completed. |
138
+
120
139
  ## Settings
121
140
 
122
141
  Settings are stored at `~/.config/mud/settings.ini`.
@@ -150,7 +169,7 @@ You can modify your `.mudconfig` file using the following commands:
150
169
 
151
170
  | Command | Description |
152
171
  |-----------------------------|------------------------------------------------|
153
- | `mud add <path>` | adds a path without a label. |
154
- | `mud add <path> <label>` | adds a path with an optional label. |
155
- | `mud remove <path>` | removes the directory with the specified path. |
156
- | `mud remove <path> <label>` | removes the label from a directory. |
172
+ | `mud add <path>`/`mud a <path>` | adds a path without a label. |
173
+ | `mud add <path> <label>`/`mud a <path> <label>` | adds a path with an optional label. |
174
+ | `mud remove <path>`/`mud rm <path>` | removes the directory with the specified path. |
175
+ | `mud remove <path> <label>`/`mud rm <path> <label>` | removes the label from a directory. |
@@ -14,6 +14,7 @@ src/mud/__init__.py
14
14
  src/mud/__main__.py
15
15
  src/mud/app.py
16
16
  src/mud/commands.py
17
+ src/mud/completion.py
17
18
  src/mud/config.py
18
19
  src/mud/runner.py
19
20
  src/mud/settings.py
@@ -29,6 +30,7 @@ src/mud_git.egg-info/scm_version.json
29
30
  src/mud_git.egg-info/top_level.txt
30
31
  tests/conftest.py
31
32
  tests/helpers.py
33
+ tests/test_completion.py
32
34
  tests/test_config.py
33
35
  tests/test_display.py
34
36
  tests/test_filters.py
@@ -16,6 +16,7 @@
16
16
  "src/mud/__main__.py",
17
17
  "src/mud/app.py",
18
18
  "src/mud/commands.py",
19
+ "src/mud/completion.py",
19
20
  "src/mud/config.py",
20
21
  "src/mud/runner.py",
21
22
  "src/mud/settings.py",
@@ -23,6 +24,7 @@
23
24
  "src/mud/utils.py",
24
25
  "tests/conftest.py",
25
26
  "tests/helpers.py",
27
+ "tests/test_completion.py",
26
28
  "tests/test_config.py",
27
29
  "tests/test_display.py",
28
30
  "tests/test_filters.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "1.1.8",
3
+ "distance": 0,
4
+ "node": "g1cc3dd62f361e5e13d6c3979cc1a747db28a846d",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-14"
8
+ }
@@ -0,0 +1,232 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from helpers import make_detached_repo, make_empty_repo, make_git_repo, run_mud
11
+
12
+
13
+ @pytest.fixture
14
+ def complete(repos_labeled: Path, home: Path):
15
+ def invoke(*arguments, cwd=repos_labeled):
16
+ env = {key: value for key, value in os.environ.items() if not key.startswith('C_ARG')}
17
+ env['HOME'] = str(home)
18
+ env['C_VALUE'] = arguments[-1]
19
+ env.update({f'C_ARG{index}': argument for index, argument in enumerate(arguments[:-1])})
20
+ result = subprocess.run(
21
+ [sys.executable, '-m', 'mud', 'completion', 'values'],
22
+ cwd=cwd, env=env, capture_output=True, text=True, check=True,
23
+ )
24
+ assert result.stderr == ''
25
+ return [line.split('\t', 1)[0] for line in result.stdout.splitlines()]
26
+ return invoke
27
+
28
+
29
+ def test_command_and_flag_inventory(complete):
30
+ from mud.app import App
31
+ from mud.commands import COMMANDS
32
+
33
+ values = complete('')
34
+ assert set(COMMANDS) <= set(values)
35
+ for action in App._create_parser()._actions:
36
+ assert {flag + ('=' if action.nargs != 0 else '') for flag in action.option_strings} <= set(values)
37
+ assert {'fetch', 'pull', 'push', '--'} <= set(values)
38
+ assert '-l=' in complete('-l')
39
+ assert '--not-name=' in complete('--not-n')
40
+ assert complete('completion', '') == ['carapace']
41
+
42
+
43
+ @pytest.mark.parametrize('prefix', ['-l=', '-L=', '--label=', '--not-label='])
44
+ def test_label_values(complete, repos_labeled: Path, prefix):
45
+ (repos_labeled / '.mudconfig').write_text('repo_a\twork, shared\nrepo_b\tpersonal,shared\n')
46
+ assert complete(prefix) == [prefix + label for label in ['personal', 'shared', 'work']]
47
+ assert complete('-l=work', prefix + 'sh') == [prefix + 'shared']
48
+
49
+
50
+ @pytest.mark.parametrize('prefix', ['-n=', '-N=', '--name=', '--not-name='])
51
+ def test_name_values(complete, repos_labeled: Path, prefix):
52
+ (repos_labeled / 'repo_a').rename(repos_labeled / 'repo with spaces')
53
+ (repos_labeled / '.mudconfig').write_text('repo with spaces\twork\nrepo_b\tpersonal\n')
54
+ assert complete(prefix) == [prefix + 'repo with spaces', prefix + 'repo_b']
55
+
56
+
57
+ @pytest.mark.parametrize('prefix', ['-b=', '-B=', '--branch=', '--not-branch='])
58
+ def test_all_branch_values(complete, repos_labeled: Path, prefix):
59
+ for name in ['repo_a', 'repo_b']:
60
+ path = repos_labeled / name
61
+ for command in [
62
+ ['branch', '-M', 'master'],
63
+ ['branch', 'feature/inactive'],
64
+ ['update-ref', 'refs/remotes/origin/release/remote', 'HEAD'],
65
+ ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/release/remote'],
66
+ ]:
67
+ subprocess.run(['git', *command], cwd=path, capture_output=True, check=True)
68
+ assert complete(prefix) == [prefix + name for name in ['feature/inactive', 'master', 'release/remote']]
69
+ assert complete(prefix + 'feature/') == [prefix + 'feature/inactive']
70
+
71
+
72
+ @pytest.mark.parametrize('arguments', [
73
+ ('--', ''), ('--', '-l='), ('--', 'git', ''),
74
+ ('-c=',), ('--command=git ',), ('-c=git status', 'git', ''),
75
+ ('git', ''), ('git', '-l='), ('fetch', ''), ('status', '-l='), ('add', '--', ''),
76
+ ('-l=work', 'completion', ''),
77
+ ('add', 'repo_a', 'work', ''),
78
+ ])
79
+ def test_opaque_commands(complete, arguments):
80
+ assert complete(*arguments) == []
81
+
82
+
83
+ def test_filters_after_explicit_command(complete):
84
+ values = complete('-c=git status', '')
85
+ assert '-l=' in values
86
+ assert not {'status', 'fetch', 'init'} & set(values)
87
+ assert complete('--command=git status', '-L=') == ['-L=label_a', '-L=label_b']
88
+
89
+
90
+ def test_native_positionals(complete, repos_labeled: Path):
91
+ assert complete('remove', '') == ['repo_a', 'repo_b']
92
+ assert complete('rm', 'repo_a', '') == ['label_a', 'label_b']
93
+ assert complete('a', 'repo_') == ['repo_a/', 'repo_b/']
94
+ assert complete('add', 'repo_a', 'label_') == ['label_a', 'label_b']
95
+ assert complete('set-global', '.mud') == ['.mudconfig']
96
+ assert complete('add', 'repo_', cwd=repos_labeled / 'repo_a') == ['repo_a/', 'repo_b/']
97
+ assert complete('status', '--h') == ['--help']
98
+ assert 'completion' not in complete('-l=work', '')
99
+
100
+
101
+ def test_read_only_discovery(complete, repos_labeled: Path, home: Path, tmp_path_factory):
102
+ assert complete('-l=', cwd=repos_labeled / 'repo_a') == ['-l=label_a', '-l=label_b']
103
+ assert not (home / '.config').exists()
104
+ settings = home / '.config' / 'mud' / 'settings.ini'
105
+ settings.parent.mkdir(parents=True)
106
+ settings.write_text(f'[mud]\nconfig_path = {repos_labeled / ".mudconfig"}\n[alias]\nto = git checkout\n')
107
+ assert complete('-n=', cwd=tmp_path_factory.mktemp('outside-config')) == ['-n=repo_a', '-n=repo_b']
108
+ assert 'to' in complete('')
109
+
110
+
111
+ def test_invalid_and_empty_configs(complete, repos_labeled: Path, home: Path):
112
+ config = repos_labeled / '.mudconfig'
113
+ config.unlink()
114
+ assert complete('-l=', cwd=home) == []
115
+ config.write_text('missing\tbad\nrepo_a\tgood\n')
116
+ assert complete('-l=') == ['-l=good']
117
+ for contents in ['', '\n', 'missing\tbad\n']:
118
+ config.write_text(contents)
119
+ assert complete('-b=') == []
120
+ assert complete('-l=') == []
121
+
122
+
123
+ def test_unborn_and_detached_repositories(complete, repos_labeled: Path):
124
+ make_empty_repo(repos_labeled / 'unborn')
125
+ make_detached_repo(repos_labeled / 'detached')
126
+ (repos_labeled / '.mudconfig').write_text('unborn\tx\ndetached\ty\n')
127
+ assert complete('-l=') == ['-l=x', '-l=y']
128
+ assert all('HEAD' not in value for value in complete('-b='))
129
+
130
+
131
+ @pytest.fixture
132
+ def carapace(repos_labeled: Path, home: Path):
133
+ if not shutil.which('carapace'):
134
+ pytest.skip('carapace is not installed')
135
+ spec = run_mud('completion', 'carapace', cwd=home, home=home)
136
+ assert spec.returncode == 0, spec.stderr
137
+ assert not (home / '.config').exists()
138
+ spec_directory = home / '.config' / 'carapace' / 'specs'
139
+ spec_directory.mkdir(parents=True)
140
+ (spec_directory / 'mud.yaml').write_text(spec.stdout)
141
+ env = os.environ.copy()
142
+ env.update({
143
+ 'HOME': str(home), 'XDG_CONFIG_HOME': str(home / '.config'),
144
+ 'XDG_CACHE_HOME': str(home / '.cache'), 'CARAPACE_BRIDGES': '',
145
+ 'PATH': str(Path(sys.executable).parent) + os.pathsep + env['PATH'],
146
+ })
147
+
148
+ def invoke(*arguments, shell='export'):
149
+ result = subprocess.run(
150
+ ['carapace', 'mud', shell, 'mud', *arguments], cwd=repos_labeled,
151
+ env=env, capture_output=True, text=True, check=True,
152
+ )
153
+ assert result.stderr == ''
154
+ return json.loads(result.stdout) if shell in ['export', 'nushell'] else result.stdout
155
+ return invoke
156
+
157
+
158
+ def test_carapace_protocol(carapace):
159
+ result = carapace('-l=')
160
+ assert [value['value'] for value in result['values']] == ['-l=label_a', '-l=label_b']
161
+ assert all(value['description'] == 'Label' for value in result['values'])
162
+
163
+
164
+ @pytest.mark.parametrize('prefix', [
165
+ '-l=', '-L=', '--label=', '--not-label=',
166
+ '-n=', '-N=', '--name=', '--not-name=',
167
+ '-b=', '-B=', '--branch=', '--not-branch=',
168
+ ])
169
+ def test_carapace_dynamic_flags(carapace, complete, prefix):
170
+ assert [item['value'] for item in carapace(prefix)['values']] == complete(prefix)
171
+
172
+
173
+ @pytest.mark.parametrize('arguments', [
174
+ ('--', ''), ('--', '-l='), ('--', 'git', ''),
175
+ ('-c=',), ('--command=git ',), ('git', ''), ('git', '-l='),
176
+ ])
177
+ def test_carapace_opaque_commands(carapace, arguments):
178
+ assert carapace(*arguments)['values'] == []
179
+ assert carapace(*arguments, shell='nushell') == []
180
+
181
+
182
+ def test_carapace_filters_after_command(carapace):
183
+ values = {item['value'] for item in carapace('-c=git status', '')['values']}
184
+ assert {'-l=', '--not-name='} <= values
185
+ assert not {'status', 'fetch'} & values
186
+ assert [item['value'] for item in carapace('-c=git status', '-N=')['values']] == ['-N=repo_a', '-N=repo_b']
187
+
188
+
189
+ @pytest.mark.parametrize('shell', ['nushell', 'bash', 'zsh', 'fish'])
190
+ def test_carapace_shell_formats(carapace, shell):
191
+ result = carapace('-l=label_a', shell=shell)
192
+ if shell == 'nushell':
193
+ assert [item['value'] for item in result] == ['-l=label_a ']
194
+ else:
195
+ assert '-l=label_a' in result
196
+
197
+
198
+ def test_carapace_quoting_and_no_execution(carapace, repos_labeled: Path, home: Path):
199
+ (repos_labeled / 'repo_a').rename(repos_labeled / 'repo with spaces')
200
+ (repos_labeled / '.mudconfig').write_text('repo with spaces\twork\nrepo_b\tpersonal\n')
201
+ values = carapace('-n=', shell='nushell')
202
+ assert [item['value'] for item in values] == ['"-n=repo with spaces" ', '-n=repo_b ']
203
+ assert [item['value'] for item in carapace('-n', shell='nushell')] == ['-n=']
204
+ marker = home / 'executed'
205
+ for command in [f'$(touch {marker})', f'`touch {marker}`', f'"; touch {marker}; #']:
206
+ assert carapace('-c=' + command)['values'] == []
207
+ assert len(carapace('-c=' + command, '-l=')['values']) == 2
208
+ assert carapace('--', command, '')['values'] == []
209
+ assert not marker.exists()
210
+ assert not (home / '.config' / 'mud').exists()
211
+
212
+
213
+ def test_nushell_completion_round_trip(carapace, repos_labeled: Path, home: Path):
214
+ if not shutil.which('nu'):
215
+ pytest.skip('nushell is not installed')
216
+ (repos_labeled / 'repo_a').rename(repos_labeled / 'repo with spaces')
217
+ (repos_labeled / '.mudconfig').write_text('repo with spaces\twork\n')
218
+ value = carapace('-n=', shell='nushell')[0]['value']
219
+ result = subprocess.run(
220
+ ['nu', '--no-config-file', '-c',
221
+ f'^{sys.executable} -c \'import json, sys; print(json.dumps(sys.argv[1:]))\' {value}'],
222
+ cwd=home, env={**os.environ, 'HOME': str(home), 'XDG_CONFIG_HOME': str(home / '.config')},
223
+ capture_output=True, text=True, check=True,
224
+ )
225
+ assert json.loads(result.stdout) == ['-n=repo with spaces']
226
+
227
+
228
+ def test_carapace_alias_description_is_single_line(carapace, home: Path):
229
+ (home / '.mudsettings').write_text('[alias]\ncustom = git status\n forged-token\tForged description\x1b\n')
230
+ values = carapace('')['values']
231
+ assert 'forged-token' not in {item['value'] for item in values}
232
+ assert all('\x1b' not in item['description'] for item in values)
@@ -5,6 +5,7 @@ All shell commands here use -a to run in ordered mode so the output is
5
5
  straightforward to assert on.
6
6
  """
7
7
  import subprocess
8
+ import pytest
8
9
  from pathlib import Path
9
10
  from helpers import run_mud
10
11
 
@@ -41,6 +42,41 @@ def test_name_filter(repos: Path, home: Path):
41
42
  assert "repo_b" not in result.stdout
42
43
 
43
44
 
45
+ @pytest.mark.parametrize("command", [
46
+ ("status",),
47
+ ("echo", "hello"),
48
+ ("--", "echo", "hello"),
49
+ ("-c=echo hello",),
50
+ ], ids=["native", "passthrough", "separator", "explicit-command"])
51
+ @pytest.mark.parametrize("filters, expected", [
52
+ (("-N=po_a",), ("repo_b",)),
53
+ (("--not-name=po_b",), ("repo_a",)),
54
+ (("-N=po_a", "-N=po_b"), ()),
55
+ (("--not-name=po_a", "--not-name=po_b"), ()),
56
+ (("-N=po_a", "--not-name=po_b"), ()),
57
+ (("-n=repo", "-N=po_a"), ("repo_b",)),
58
+ (("--name=repo", "--not-name=po_b"), ("repo_a",)),
59
+ (("-n=po_a", "-N=po_a"), ()),
60
+ (("-N=missing",), ("repo_a", "repo_b")),
61
+ (("--not-name=PO_A",), ("repo_a", "repo_b")),
62
+ (("-N=",), ("repo_a", "repo_b")),
63
+ (("-N=", "-N=repo_a"), ("repo_b",)),
64
+ ])
65
+ def test_name_exclude_filter(repos: Path, home: Path, command, filters, expected):
66
+ result = run_mud("-a", *filters, *command, cwd=repos, home=home)
67
+ assert result.returncode == 0, result.stderr
68
+ for name in ("repo_a", "repo_b"):
69
+ assert (name in result.stdout) == (name in expected), result.stdout
70
+
71
+
72
+ @pytest.mark.parametrize('flag', ['-t', '--table', '-n=', '-N=', '-l=', '-L=', '-b=', '-B='])
73
+ def test_flags_do_not_consume_native_command(repos: Path, home: Path, flag):
74
+ result = run_mud(flag, 'status', cwd=repos, home=home)
75
+ assert result.returncode == 0, result.stderr
76
+ assert 'repo_a' in result.stdout
77
+ assert 'repo_b' in result.stdout
78
+
79
+
44
80
  def test_branch_filter(repos_labeled: Path, home: Path):
45
81
  """-b=<branch> restricts execution to repos currently on that branch."""
46
82
  # Put repo_a on a feature branch; repo_b stays on the default branch.
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "1.1.7",
3
- "distance": 0,
4
- "node": "ge2409c646f484a43da494781859f961bb3c79713",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-16"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes