Vernon Wee Hong KOH ha revisionato questo gist 1 day ago. Vai alla revisione
7 files changed, 260 insertions, 319 deletions
README.md
| @@ -1,5 +1,6 @@ | |||
| 1 | - | # Bash Script Installer for Zsh | |
| 2 | - | The "Bash Script Installer" simplifies the setup of Zsh. | |
| 1 | + | # Zsh Setup | |
| 2 | + | ||
| 3 | + | Cross-platform Zsh installers and managed dotfiles for Oh My Zsh and Starship. | |
| 3 | 4 | ||
| 4 | 5 | ## Zsh | |
| 5 | 6 | ### Ubuntu | |
| @@ -10,7 +11,7 @@ bash -c "$(curl -fsSL https://tinyurl.com/y6fhb594)" | |||
| 10 | 11 | ```zsh | |
| 11 | 12 | bash -c "$(curl -fsSL https://tinyurl.com/ycyct6wp)" | |
| 12 | 13 | ``` | |
| 13 | - | ### MacOS | |
| 14 | + | ### macOS | |
| 14 | 15 | ```zsh | |
| 15 | 16 | bash -c "$(curl -fsSL https://tinyurl.com/yptybtpa)" | |
| 16 | 17 | ``` | |
| @@ -20,7 +21,28 @@ bash -c "$(curl -fsSL https://tinyurl.com/yptybtpa)" | |||
| 20 | 21 | bash -c "$(curl -fsSL https://tinyurl.com/y8vf753j)" | |
| 21 | 22 | ``` | |
| 22 | 23 | ||
| 23 | - | ### Shell framework and prompt | |
| 24 | + | The platform installers share `config.sh`, which owns the managed-file list and | |
| 25 | + | download logic. Both configuration menu options install the canonical `zshrc` | |
| 26 | + | file. Existing files are replaced only after a non-empty download succeeds; | |
| 27 | + | failures return a nonzero status. Updates are atomic per file, not across the | |
| 28 | + | whole configuration set. | |
| 29 | + | ||
| 30 | + | When run from a checkout, installers load the adjacent `config.sh`. The | |
| 31 | + | curl-based entry points fetch it from the gist. `GIST_RAW_BASE` overrides the | |
| 32 | + | download base URL for both the helper and managed files. | |
| 33 | + | ||
| 34 | + | ## Repository layout | |
| 35 | + | ||
| 36 | + | | Files | Purpose | | |
| 37 | + | | --- | --- | | |
| 38 | + | | `zsh_ubuntu.sh`, `zsh_wsl.sh`, `zsh_macos.sh` | Platform setup and interactive menus | | |
| 39 | + | | `config.sh` | Shared configuration installer; also runnable on its own | | |
| 40 | + | | `zshrc`, `alias`, `func`, `pathrc`, `sourcerc`, `vimrc` | Managed shell and editor dotfiles | | |
| 41 | + | | `starship.toml` | Prompt configuration | | |
| 42 | + | | `ai-commit*.md`, `ai-commit*.txt` | Commit agents and prompts | | |
| 43 | + | | `test_*.py`, `test_plan_build.zsh` | Isolated regression tests | | |
| 44 | + | ||
| 45 | + | ## Shell framework and prompt | |
| 24 | 46 | The installers use Oh My Zsh for framework features and plugins, with the OMZ theme disabled: | |
| 25 | 47 | ||
| 26 | 48 | ```zsh | |
| @@ -41,11 +63,12 @@ aicommit opencode | |||
| 41 | 63 | ||
| 42 | 64 | The installers place the shared commit instructions at `~/.config/ai-commit-prompt.txt` and the required OpenCode agent at `~/.config/opencode/agents/ai-commit.md`. The agent is hidden from the interactive agent selector but remains available to `aicommit opencode`. It may inspect Git status, the cached diff, and recent history, and may execute `git commit`; all unrelated tools and shell commands are denied. | |
| 43 | 65 | ||
| 44 | - | **Changes affecting more than 30 staged paths automatically use per-module commits.** They must produce multiple commits, rather than a single combined commit message. The count uses the full staged set before diff filtering or truncation, including lock/minified files and both paths of a rename. Exactly 30 paths retains the single-commit default. Pass `--modules` to request splitting at any size: | |
| 66 | + | **Changes affecting more than 30 staged paths default to per-module commits.** In an interactive terminal, the function asks whether to split; declining requests one combined commit. Without a terminal, or with `--yes`, it splits automatically. Use `--single` to explicitly request one commit, or `--modules` to split at any size; these options are mutually exclusive. The count uses the full staged set before diff filtering or truncation, including lock/minified files and both paths of a rename. Exactly 30 paths retains the single-commit default: | |
| 45 | 67 | ||
| 46 | 68 | ```zsh | |
| 47 | 69 | aicommit --modules opencode | |
| 48 | 70 | aicommit --modules --yes claude | |
| 71 | + | aicommit --single opencode | |
| 49 | 72 | ``` | |
| 50 | 73 | ||
| 51 | 74 | Automatic splitting uses the same planning and confirmation flow as `--modules`; `--yes` skips confirmation in either case (for example, `aicommit --yes opencode`). Plans that collapse a change above the threshold into one group are rejected before committing, with the index untouched. Grouping follows cohesive modules or submodules; 30 is the automatic-splitting threshold, not a hard size limit per planned module. | |
| @@ -75,3 +98,18 @@ The canonical executable defines the current command behavior and options. Each | |||
| 75 | 98 | The launcher defaults to `https://opengist.resetrix.work/weehong/plan-build/raw/HEAD` and `${XDG_CACHE_HOME:-$HOME/.cache}/plan-build`. Override these with `PLAN_BUILD_BASE_URL` and `PLAN_BUILD_CACHE_DIR`. Installed skill paths remain fixed at `~/.claude/skills`; cached invocations receive immutable release paths through environment variables. The downloaded script is remote executable code: syntax validation catches malformed Zsh but is not a security sandbox, so only point `PLAN_BUILD_BASE_URL` at a source you trust. Remote code is executed in a child Zsh process and is never sourced into the caller shell. | |
| 76 | 99 | ||
| 77 | 100 | Invoking `plan_build` once installs or repairs both skill links. To install standalone files without the cached launcher, follow the installation instructions in the canonical gist instead. | |
| 101 | + | ||
| 102 | + | ## Development checks | |
| 103 | + | ||
| 104 | + | Run from the repository root with Bash, Zsh, Python 3, Git, and curl installed: | |
| 105 | + | ||
| 106 | + | ```sh | |
| 107 | + | for file in *.sh; do bash -n "$file" || exit; done | |
| 108 | + | for file in alias func pathrc sourcerc zshrc; do zsh -n "$file" || exit; done | |
| 109 | + | PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -p 'test_*.py' | |
| 110 | + | zsh -f test_plan_build.zsh | |
| 111 | + | git diff --check | |
| 112 | + | ``` | |
| 113 | + | ||
| 114 | + | Tests use temporary homes and local fixtures rather than installing packages or | |
| 115 | + | invoking AI services. | |
config.sh
| @@ -1,8 +1,7 @@ | |||
| 1 | 1 | #!/bin/bash | |
| 2 | - | set -euo pipefail | |
| 2 | + | # Shared configuration installer; safe to source from the platform installers. | |
| 3 | 3 | ||
| 4 | - | # Configuration | |
| 5 | - | GIST_RAW_BASE="https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD" | |
| 4 | + | GIST_RAW_BASE="${GIST_RAW_BASE:-https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD}" | |
| 6 | 5 | CONFIG_FILES=( | |
| 7 | 6 | ".alias" | |
| 8 | 7 | ".func" | |
| @@ -17,37 +16,44 @@ CONFIG_FILES=( | |||
| 17 | 16 | ".config/opencode/agents/ai-commit-plan.md" | |
| 18 | 17 | ) | |
| 19 | 18 | ||
| 20 | - | echo "Starting configuration download..." | |
| 21 | - | download_failed=0 | |
| 19 | + | download_config() ( | |
| 20 | + | # A subshell keeps temporary-file traps out of the caller's shell. | |
| 21 | + | local file="$1" remote_name target tmp | |
| 22 | + | remote_name="${file##*/}" | |
| 23 | + | remote_name="${remote_name#.}" | |
| 24 | + | target="$HOME/$file" | |
| 25 | + | mkdir -p "$(dirname "$target")" || return 1 | |
| 26 | + | tmp="$(mktemp "${target}.tmp.XXXXXX")" || return 1 | |
| 27 | + | trap 'rm -f "$tmp"' EXIT | |
| 28 | + | trap 'exit 1' HUP INT TERM | |
| 22 | 29 | ||
| 23 | - | for f in "${CONFIG_FILES[@]}"; do | |
| 24 | - | # Remove the leading dot for the URL path | |
| 25 | - | remote_name="${f#.}" | |
| 26 | - | [[ "$f" == ".config/starship.toml" ]] && remote_name="starship.toml" | |
| 27 | - | [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_name="ai-commit-prompt.txt" | |
| 28 | - | [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_name="ai-commit-split-prompt.txt" | |
| 29 | - | [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_name="ai-commit.md" | |
| 30 | - | [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_name="ai-commit-plan.md" | |
| 31 | - | url="$GIST_RAW_BASE/$remote_name" | |
| 32 | - | target="$HOME/$f" | |
| 33 | - | tmp="${target}.tmp.$$" | |
| 34 | - | ||
| 35 | - | echo "Downloading $f..." | |
| 36 | - | mkdir -p "$(dirname "$target")" | |
| 37 | - | ||
| 38 | - | # Use -f to fail silently on server errors, -s for silent, -L to follow redirects | |
| 39 | - | if curl -fsSL "$url" -o "$tmp" && mv "$tmp" "$target"; then | |
| 40 | - | echo "Successfully updated $target" | |
| 30 | + | printf 'Downloading %s...\n' "$file" | |
| 31 | + | if curl -fsSL "${GIST_RAW_BASE%/}/$remote_name" -o "$tmp" && | |
| 32 | + | [[ -s "$tmp" ]] && mv "$tmp" "$target"; then | |
| 33 | + | printf 'Successfully updated %s\n' "$target" | |
| 41 | 34 | else | |
| 42 | - | rm -f "$tmp" | |
| 43 | - | echo "Error: Failed to download $f from $url" >&2 | |
| 44 | - | download_failed=1 | |
| 35 | + | printf 'Error: Failed to install %s\n' "$file" >&2 | |
| 36 | + | return 1 | |
| 45 | 37 | fi | |
| 46 | - | done | |
| 38 | + | ) | |
| 47 | 39 | ||
| 48 | - | if [ "$download_failed" -ne 0 ]; then | |
| 49 | - | echo "Configuration download completed with errors." >&2 | |
| 50 | - | exit 1 | |
| 51 | - | fi | |
| 40 | + | download_configs() { | |
| 41 | + | local file failed=0 | |
| 42 | + | for file in "${CONFIG_FILES[@]}"; do | |
| 43 | + | download_config "$file" || failed=1 | |
| 44 | + | done | |
| 45 | + | if [[ "$failed" -ne 0 ]]; then | |
| 46 | + | printf 'Configuration download completed with errors.\n' >&2 | |
| 47 | + | return 1 | |
| 48 | + | fi | |
| 49 | + | printf 'Done! All configuration files have been replaced.\n' | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | configure_zshrc() { | |
| 53 | + | download_config .zshrc | |
| 54 | + | } | |
| 52 | 55 | ||
| 53 | - | echo "Done! All configuration files have been replaced." | |
| 56 | + | if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then | |
| 57 | + | set -euo pipefail | |
| 58 | + | download_configs | |
| 59 | + | fi | |
test_aicommit.py
| @@ -68,7 +68,19 @@ class AicommitTests(unittest.TestCase): | |||
| 68 | 68 | result = self.shell(ROUTE, "--yes", engine) | |
| 69 | 69 | self.assertEqual(result.returncode, 7, result.stderr) | |
| 70 | 70 | self.assertEqual(result.stdout.strip(), f"MODULES {engine} 1") | |
| 71 | - | self.assertIn("exceed 30", result.stderr) | |
| 71 | + | ||
| 72 | + | def test_explicit_single_above_threshold(self): | |
| 73 | + | self.stage(31) | |
| 74 | + | result = self.shell(ROUTE, "--single", "opencode") | |
| 75 | + | self.assertEqual(result.returncode, 0, result.stderr) | |
| 76 | + | self.assertEqual(result.stdout.strip(), "ENGINE opencode commit") | |
| 77 | + | ||
| 78 | + | def test_conflicting_modes_never_call_engine(self): | |
| 79 | + | self.stage(1) | |
| 80 | + | result = self.shell(ROUTE, "--single", "--modules") | |
| 81 | + | self.assertNotEqual(result.returncode, 0) | |
| 82 | + | self.assertNotIn("ENGINE", result.stdout) | |
| 83 | + | self.assertNotIn("MODULES", result.stdout) | |
| 72 | 84 | ||
| 73 | 85 | def test_explicit_modules_below_threshold(self): | |
| 74 | 86 | self.stage(1) | |
test_config.py(file creato)
| @@ -0,0 +1,103 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """Test configuration downloads in a temporary home without network access.""" | |
| 3 | + | ||
| 4 | + | import os | |
| 5 | + | from pathlib import Path | |
| 6 | + | import subprocess | |
| 7 | + | import tempfile | |
| 8 | + | import unittest | |
| 9 | + | ||
| 10 | + | ||
| 11 | + | ROOT = Path(__file__).resolve().parent | |
| 12 | + | TARGETS = ( | |
| 13 | + | ".alias", ".func", ".pathrc", ".sourcerc", ".vimrc", ".zshrc", | |
| 14 | + | ".config/starship.toml", ".config/ai-commit-prompt.txt", | |
| 15 | + | ".config/ai-commit-split-prompt.txt", | |
| 16 | + | ".config/opencode/agents/ai-commit.md", | |
| 17 | + | ".config/opencode/agents/ai-commit-plan.md", | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | ||
| 21 | + | class ConfigTests(unittest.TestCase): | |
| 22 | + | def setUp(self): | |
| 23 | + | temp = tempfile.TemporaryDirectory(prefix="config-test-") | |
| 24 | + | self.addCleanup(temp.cleanup) | |
| 25 | + | self.root = Path(temp.name) | |
| 26 | + | self.home = self.root / "home" | |
| 27 | + | self.home.mkdir() | |
| 28 | + | self.env = { | |
| 29 | + | **os.environ, "HOME": str(self.home), | |
| 30 | + | "GIST_RAW_BASE": ROOT.as_uri() + "/", | |
| 31 | + | "TMPDIR": str(self.root), | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | def run_bash(self, *args): | |
| 35 | + | return subprocess.run( | |
| 36 | + | ["bash", *args], env=self.env, capture_output=True, text=True, | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | def assert_installed(self): | |
| 40 | + | for target in TARGETS: | |
| 41 | + | source = ROOT / Path(target).name.lstrip(".") | |
| 42 | + | self.assertEqual((self.home / target).read_bytes(), source.read_bytes()) | |
| 43 | + | self.assertEqual(list(self.home.rglob("*.tmp.*")), []) | |
| 44 | + | ||
| 45 | + | def test_standalone_and_inline_entry_points(self): | |
| 46 | + | for args in ((str(ROOT / "config.sh"),), ("-c", (ROOT / "config.sh").read_text())): | |
| 47 | + | with self.subTest(args=args[:1]): | |
| 48 | + | result = self.run_bash(*args) | |
| 49 | + | self.assertEqual(result.returncode, 0, result.stderr) | |
| 50 | + | self.assert_installed() | |
| 51 | + | ||
| 52 | + | def test_source_has_no_download_side_effects(self): | |
| 53 | + | result = self.run_bash("-c", 'source "$1"', "test", str(ROOT / "config.sh")) | |
| 54 | + | self.assertEqual(result.returncode, 0, result.stderr) | |
| 55 | + | self.assertEqual(list(self.home.iterdir()), []) | |
| 56 | + | ||
| 57 | + | def test_failed_and_empty_downloads_preserve_existing_files(self): | |
| 58 | + | remote = self.root / "remote" | |
| 59 | + | remote.mkdir() | |
| 60 | + | self.env["GIST_RAW_BASE"] = remote.as_uri() | |
| 61 | + | for target in TARGETS: | |
| 62 | + | path = self.home / target | |
| 63 | + | path.parent.mkdir(parents=True, exist_ok=True) | |
| 64 | + | path.write_text("existing config\n") | |
| 65 | + | # One empty response, one successful response, all others missing. | |
| 66 | + | (remote / "zshrc").touch() | |
| 67 | + | (remote / "alias").write_text("new aliases\n") | |
| 68 | + | result = self.run_bash(str(ROOT / "config.sh")) | |
| 69 | + | self.assertNotEqual(result.returncode, 0) | |
| 70 | + | self.assertNotIn("Done!", result.stdout) | |
| 71 | + | for target in TARGETS: | |
| 72 | + | expected = "new aliases\n" if target == ".alias" else "existing config\n" | |
| 73 | + | self.assertEqual((self.home / target).read_text(), expected) | |
| 74 | + | self.assertEqual(list(self.home.rglob("*.tmp.*")), []) | |
| 75 | + | ||
| 76 | + | def test_platform_loaders_local_and_remote(self): | |
| 77 | + | for name in ("zsh_ubuntu.sh", "zsh_wsl.sh", "zsh_macos.sh"): | |
| 78 | + | for local in (True, False): | |
| 79 | + | with self.subTest(platform=name, local=local): | |
| 80 | + | script = ROOT / name | |
| 81 | + | if not local: | |
| 82 | + | # An isolated script has no adjacent config.sh, just like | |
| 83 | + | # the curl entry point, and must fetch the shared code. | |
| 84 | + | script = self.root / name | |
| 85 | + | script.write_text((ROOT / name).read_text()) | |
| 86 | + | result = self.run_bash( | |
| 87 | + | "-c", 'source "$1"; load_config_installer; download_configs', | |
| 88 | + | "test", str(script), | |
| 89 | + | ) | |
| 90 | + | self.assertEqual(result.returncode, 0, result.stderr) | |
| 91 | + | self.assert_installed() | |
| 92 | + | ||
| 93 | + | def test_zshrc_only_uses_canonical_file(self): | |
| 94 | + | result = self.run_bash( | |
| 95 | + | "-c", 'source "$1"; configure_zshrc', "test", str(ROOT / "config.sh"), | |
| 96 | + | ) | |
| 97 | + | self.assertEqual(result.returncode, 0, result.stderr) | |
| 98 | + | self.assertEqual((self.home / ".zshrc").read_bytes(), (ROOT / "zshrc").read_bytes()) | |
| 99 | + | self.assertEqual(list(self.home.iterdir()), [self.home / ".zshrc"]) | |
| 100 | + | ||
| 101 | + | ||
| 102 | + | if __name__ == "__main__": | |
| 103 | + | unittest.main() | |
zsh_macos.sh
| @@ -18,21 +18,7 @@ err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } | |||
| 18 | 18 | # ============================= | |
| 19 | 19 | # CONFIG | |
| 20 | 20 | # ============================= | |
| 21 | - | GIST_RAW_BASE="https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD" | |
| 22 | - | ||
| 23 | - | CONFIG_FILES=( | |
| 24 | - | ".alias" | |
| 25 | - | ".func" | |
| 26 | - | ".pathrc" | |
| 27 | - | ".sourcerc" | |
| 28 | - | ".vimrc" | |
| 29 | - | ".zshrc" | |
| 30 | - | ".config/starship.toml" | |
| 31 | - | ".config/ai-commit-prompt.txt" | |
| 32 | - | ".config/ai-commit-split-prompt.txt" | |
| 33 | - | ".config/opencode/agents/ai-commit.md" | |
| 34 | - | ".config/opencode/agents/ai-commit-plan.md" | |
| 35 | - | ) | |
| 21 | + | GIST_RAW_BASE="${GIST_RAW_BASE:-https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD}" | |
| 36 | 22 | ||
| 37 | 23 | # ============================= | |
| 38 | 24 | # REQUIREMENTS | |
| @@ -87,90 +73,27 @@ install_starship() { | |||
| 87 | 73 | fi | |
| 88 | 74 | } | |
| 89 | 75 | ||
| 90 | - | download_configs() { | |
| 91 | - | log "Downloading custom config files..." | |
| 92 | - | for f in "${CONFIG_FILES[@]}"; do | |
| 93 | - | # Strip the leading dot for the download URL | |
| 94 | - | local remote_file="${f#.}" | |
| 95 | - | [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml" | |
| 96 | - | [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt" | |
| 97 | - | [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt" | |
| 98 | - | [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md" | |
| 99 | - | [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md" | |
| 100 | - | local url="$GIST_RAW_BASE/$remote_file" | |
| 101 | - | local target="$HOME/$f" | |
| 102 | - | local tmp="${target}.tmp.$$" | |
| 103 | - | ||
| 104 | - | mkdir -p "$(dirname "$target")" | |
| 105 | - | ||
| 106 | - | log "Fetching $remote_file -> $f ..." | |
| 107 | - | if curl -fsSL "$url" -o "$tmp"; then | |
| 108 | - | mv "$tmp" "$target" | |
| 109 | - | else | |
| 76 | + | # Load the shared installer locally or fetch it for the curl-based entry point. | |
| 77 | + | load_config_installer() { | |
| 78 | + | local script_dir="" tmp | |
| 79 | + | if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then | |
| 80 | + | script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" | |
| 81 | + | fi | |
| 82 | + | if [[ -n "$script_dir" && -f "$script_dir/config.sh" ]]; then | |
| 83 | + | source "$script_dir/config.sh" | |
| 84 | + | else | |
| 85 | + | tmp="$(mktemp)" || return 1 | |
| 86 | + | if ! curl -fsSL "${GIST_RAW_BASE%/}/config.sh" -o "$tmp" || | |
| 87 | + | [[ ! -s "$tmp" ]] || ! bash -n "$tmp"; then | |
| 110 | 88 | rm -f "$tmp" | |
| 111 | - | warn "Failed to download $remote_file" | |
| 89 | + | err "Failed to load the shared configuration installer" | |
| 90 | + | return 1 | |
| 112 | 91 | fi | |
| 113 | - | done | |
| 114 | - | ||
| 115 | - | ok "Configs downloaded" | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | configure_zshrc() { | |
| 119 | - | local zshrc="$HOME/.zshrc" | |
| 120 | - | log "Configuring .zshrc for Oh My Zsh and Starship..." | |
| 121 | - | ||
| 122 | - | cat > "$zshrc" <<'EOF' | |
| 123 | - | # ============================================================================= | |
| 124 | - | # 1. HELPER FUNCTIONS & PATH | |
| 125 | - | # ============================================================================= | |
| 126 | - | source_if_readable() { | |
| 127 | - | local file="$1" | |
| 128 | - | if [[ -f "$file" && -r "$file" ]]; then | |
| 129 | - | source "$file" | |
| 92 | + | source "$tmp" | |
| 93 | + | rm -f "$tmp" | |
| 130 | 94 | fi | |
| 131 | 95 | } | |
| 132 | 96 | ||
| 133 | - | export PATH="$HOME/.local/bin:$PATH" | |
| 134 | - | ||
| 135 | - | # ============================================================================= | |
| 136 | - | # 2. OH MY ZSH FRAMEWORK | |
| 137 | - | # ============================================================================= | |
| 138 | - | export ZSH="${ZSH:-$HOME/.oh-my-zsh}" | |
| 139 | - | ZSH_THEME="" | |
| 140 | - | ||
| 141 | - | plugins=( | |
| 142 | - | git | |
| 143 | - | zsh-autosuggestions | |
| 144 | - | zsh-syntax-highlighting | |
| 145 | - | ) | |
| 146 | - | ||
| 147 | - | source_if_readable "$ZSH/oh-my-zsh.sh" | |
| 148 | - | ||
| 149 | - | # ============================================================================= | |
| 150 | - | # 3. THIRD-PARTY INITIALIZATION & CUSTOM CONFIGS | |
| 151 | - | # ============================================================================= | |
| 152 | - | source_if_readable "$HOME/.sourcerc" | |
| 153 | - | source_if_readable "$HOME/.func" | |
| 154 | - | source_if_readable "$HOME/.pathrc" | |
| 155 | - | source_if_readable "$HOME/.alias" | |
| 156 | - | ||
| 157 | - | # ============================================================================= | |
| 158 | - | # 4. STARSHIP PROMPT | |
| 159 | - | # ============================================================================= | |
| 160 | - | if [[ -z "${STARSHIP_CONFIG:-}" && -f "$HOME/.config/starship.toml" ]]; then | |
| 161 | - | export STARSHIP_CONFIG="$HOME/.config/starship.toml" | |
| 162 | - | fi | |
| 163 | - | ||
| 164 | - | if command -v starship >/dev/null 2>&1; then | |
| 165 | - | eval "$(starship init zsh)" | |
| 166 | - | elif [[ -x "$HOME/.local/bin/starship" ]]; then | |
| 167 | - | eval "$("$HOME/.local/bin/starship" init zsh)" | |
| 168 | - | fi | |
| 169 | - | EOF | |
| 170 | - | ||
| 171 | - | ok ".zshrc configured for Oh My Zsh framework with Starship prompt" | |
| 172 | - | } | |
| 173 | - | ||
| 174 | 97 | configure_git_identity() { | |
| 175 | 98 | log "Configuring global Git identity..." | |
| 176 | 99 | git config --global user.name "Vernon Wee Hong KOH" | |
| @@ -258,6 +181,7 @@ run_choices() { | |||
| 258 | 181 | # ============================= | |
| 259 | 182 | main() { | |
| 260 | 183 | check_requirements | |
| 184 | + | load_config_installer | |
| 261 | 185 | while true; do | |
| 262 | 186 | show_menu | |
| 263 | 187 | run_choices | |
| @@ -267,4 +191,6 @@ main() { | |||
| 267 | 191 | ok "macOS minimal Zsh configuration complete!" | |
| 268 | 192 | } | |
| 269 | 193 | ||
| 270 | - | main "$@" | |
| 194 | + | if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then | |
| 195 | + | main "$@" | |
| 196 | + | fi | |
zsh_ubuntu.sh
| @@ -22,20 +22,7 @@ SKIP_PACKAGES=false | |||
| 22 | 22 | SKIP_SHELL_CHANGE=false | |
| 23 | 23 | INSTALL_HOMEBREW=false | |
| 24 | 24 | ||
| 25 | - | GIST_RAW_BASE="https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD" | |
| 26 | - | CONFIG_FILES=( | |
| 27 | - | ".alias" | |
| 28 | - | ".func" | |
| 29 | - | ".pathrc" | |
| 30 | - | ".sourcerc" | |
| 31 | - | ".vimrc" | |
| 32 | - | ".zshrc" | |
| 33 | - | ".config/starship.toml" | |
| 34 | - | ".config/ai-commit-prompt.txt" | |
| 35 | - | ".config/ai-commit-split-prompt.txt" | |
| 36 | - | ".config/opencode/agents/ai-commit.md" | |
| 37 | - | ".config/opencode/agents/ai-commit-plan.md" | |
| 38 | - | ) | |
| 25 | + | GIST_RAW_BASE="${GIST_RAW_BASE:-https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD}" | |
| 39 | 26 | ||
| 40 | 27 | # ============================= | |
| 41 | 28 | # OS DETECTION | |
| @@ -179,89 +166,27 @@ install_starship() { | |||
| 179 | 166 | fi | |
| 180 | 167 | } | |
| 181 | 168 | ||
| 182 | - | download_configs() { | |
| 183 | - | log "Downloading custom config files from OpenGist..." | |
| 184 | - | for f in "${CONFIG_FILES[@]}"; do | |
| 185 | - | local remote_file="${f#.}" | |
| 186 | - | [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml" | |
| 187 | - | [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt" | |
| 188 | - | [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt" | |
| 189 | - | [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md" | |
| 190 | - | [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md" | |
| 191 | - | local url="$GIST_RAW_BASE/$remote_file" | |
| 192 | - | local target="$HOME/$f" | |
| 193 | - | local tmp="${target}.tmp.$$" | |
| 194 | - | ||
| 195 | - | mkdir -p "$(dirname "$target")" | |
| 196 | - | ||
| 197 | - | log "Fetching $remote_file -> $f ..." | |
| 198 | - | if curl -fsSL "$url" -o "$tmp"; then | |
| 199 | - | mv "$tmp" "$target" | |
| 200 | - | else | |
| 169 | + | # Load the shared installer locally or fetch it for the curl-based entry point. | |
| 170 | + | load_config_installer() { | |
| 171 | + | local script_dir="" tmp | |
| 172 | + | if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then | |
| 173 | + | script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" | |
| 174 | + | fi | |
| 175 | + | if [[ -n "$script_dir" && -f "$script_dir/config.sh" ]]; then | |
| 176 | + | source "$script_dir/config.sh" | |
| 177 | + | else | |
| 178 | + | tmp="$(mktemp)" || return 1 | |
| 179 | + | if ! curl -fsSL "${GIST_RAW_BASE%/}/config.sh" -o "$tmp" || | |
| 180 | + | [[ ! -s "$tmp" ]] || ! bash -n "$tmp"; then | |
| 201 | 181 | rm -f "$tmp" | |
| 202 | - | warn "Failed to download $remote_file" | |
| 182 | + | err "Failed to load the shared configuration installer" | |
| 183 | + | return 1 | |
| 203 | 184 | fi | |
| 204 | - | done | |
| 205 | - | ||
| 206 | - | ok "Configs downloaded" | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | configure_zshrc() { | |
| 210 | - | local zshrc="$HOME/.zshrc" | |
| 211 | - | log "Configuring .zshrc for Oh My Zsh and Starship..." | |
| 212 | - | ||
| 213 | - | cat > "$zshrc" <<'EOF' | |
| 214 | - | # ============================================================================= | |
| 215 | - | # 1. HELPER FUNCTIONS & PATH | |
| 216 | - | # ============================================================================= | |
| 217 | - | source_if_readable() { | |
| 218 | - | local file="$1" | |
| 219 | - | if [[ -f "$file" && -r "$file" ]]; then | |
| 220 | - | source "$file" | |
| 185 | + | source "$tmp" | |
| 186 | + | rm -f "$tmp" | |
| 221 | 187 | fi | |
| 222 | 188 | } | |
| 223 | 189 | ||
| 224 | - | export PATH="$HOME/.local/bin:$PATH" | |
| 225 | - | ||
| 226 | - | # ============================================================================= | |
| 227 | - | # 2. OH MY ZSH FRAMEWORK | |
| 228 | - | # ============================================================================= | |
| 229 | - | export ZSH="${ZSH:-$HOME/.oh-my-zsh}" | |
| 230 | - | ZSH_THEME="" | |
| 231 | - | ||
| 232 | - | plugins=( | |
| 233 | - | git | |
| 234 | - | zsh-autosuggestions | |
| 235 | - | zsh-syntax-highlighting | |
| 236 | - | ) | |
| 237 | - | ||
| 238 | - | source_if_readable "$ZSH/oh-my-zsh.sh" | |
| 239 | - | ||
| 240 | - | # ============================================================================= | |
| 241 | - | # 3. THIRD-PARTY INITIALIZATION & CUSTOM CONFIGS | |
| 242 | - | # ============================================================================= | |
| 243 | - | source_if_readable "$HOME/.sourcerc" | |
| 244 | - | source_if_readable "$HOME/.func" | |
| 245 | - | source_if_readable "$HOME/.pathrc" | |
| 246 | - | source_if_readable "$HOME/.alias" | |
| 247 | - | ||
| 248 | - | # ============================================================================= | |
| 249 | - | # 4. STARSHIP PROMPT | |
| 250 | - | # ============================================================================= | |
| 251 | - | if [[ -z "${STARSHIP_CONFIG:-}" && -f "$HOME/.config/starship.toml" ]]; then | |
| 252 | - | export STARSHIP_CONFIG="$HOME/.config/starship.toml" | |
| 253 | - | fi | |
| 254 | - | ||
| 255 | - | if command -v starship >/dev/null 2>&1; then | |
| 256 | - | eval "$(starship init zsh)" | |
| 257 | - | elif [[ -x "$HOME/.local/bin/starship" ]]; then | |
| 258 | - | eval "$("$HOME/.local/bin/starship" init zsh)" | |
| 259 | - | fi | |
| 260 | - | EOF | |
| 261 | - | ||
| 262 | - | ok ".zshrc configured for Oh My Zsh framework with Starship prompt" | |
| 263 | - | } | |
| 264 | - | ||
| 265 | 190 | configure_git_identity() { | |
| 266 | 191 | log "Configuring global Git identity..." | |
| 267 | 192 | git config --global user.name "Vernon Wee Hong KOH" | |
| @@ -355,6 +280,7 @@ run_choices() { | |||
| 355 | 280 | # ============================= | |
| 356 | 281 | main() { | |
| 357 | 282 | check_requirements | |
| 283 | + | load_config_installer | |
| 358 | 284 | while true; do | |
| 359 | 285 | show_menu | |
| 360 | 286 | run_choices | |
| @@ -364,4 +290,6 @@ main() { | |||
| 364 | 290 | ok "Zsh installation/configuration complete!" | |
| 365 | 291 | } | |
| 366 | 292 | ||
| 367 | - | main "$@" | |
| 293 | + | if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then | |
| 294 | + | main "$@" | |
| 295 | + | fi | |
zsh_wsl.sh
| @@ -22,20 +22,7 @@ SKIP_PACKAGES=false | |||
| 22 | 22 | SKIP_SHELL_CHANGE=false | |
| 23 | 23 | INSTALL_HOMEBREW=false | |
| 24 | 24 | ||
| 25 | - | GIST_RAW_BASE="https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD" | |
| 26 | - | CONFIG_FILES=( | |
| 27 | - | ".alias" | |
| 28 | - | ".func" | |
| 29 | - | ".pathrc" | |
| 30 | - | ".sourcerc" | |
| 31 | - | ".vimrc" | |
| 32 | - | ".zshrc" | |
| 33 | - | ".config/starship.toml" | |
| 34 | - | ".config/ai-commit-prompt.txt" | |
| 35 | - | ".config/ai-commit-split-prompt.txt" | |
| 36 | - | ".config/opencode/agents/ai-commit.md" | |
| 37 | - | ".config/opencode/agents/ai-commit-plan.md" | |
| 38 | - | ) | |
| 25 | + | GIST_RAW_BASE="${GIST_RAW_BASE:-https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD}" | |
| 39 | 26 | ||
| 40 | 27 | # ============================= | |
| 41 | 28 | # PLATFORM DETECTION | |
| @@ -181,89 +168,27 @@ install_starship() { | |||
| 181 | 168 | fi | |
| 182 | 169 | } | |
| 183 | 170 | ||
| 184 | - | download_configs() { | |
| 185 | - | log "Downloading custom config files from OpenGist..." | |
| 186 | - | for f in "${CONFIG_FILES[@]}"; do | |
| 187 | - | local remote_file="${f#.}" | |
| 188 | - | [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml" | |
| 189 | - | [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt" | |
| 190 | - | [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt" | |
| 191 | - | [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md" | |
| 192 | - | [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md" | |
| 193 | - | local url="$GIST_RAW_BASE/$remote_file" | |
| 194 | - | local target="$HOME/$f" | |
| 195 | - | local tmp="${target}.tmp.$$" | |
| 196 | - | ||
| 197 | - | mkdir -p "$(dirname "$target")" | |
| 198 | - | ||
| 199 | - | log "Fetching $remote_file -> $f ..." | |
| 200 | - | if curl -fsSL "$url" -o "$tmp"; then | |
| 201 | - | mv "$tmp" "$target" | |
| 202 | - | else | |
| 171 | + | # Load the shared installer locally or fetch it for the curl-based entry point. | |
| 172 | + | load_config_installer() { | |
| 173 | + | local script_dir="" tmp | |
| 174 | + | if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then | |
| 175 | + | script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" | |
| 176 | + | fi | |
| 177 | + | if [[ -n "$script_dir" && -f "$script_dir/config.sh" ]]; then | |
| 178 | + | source "$script_dir/config.sh" | |
| 179 | + | else | |
| 180 | + | tmp="$(mktemp)" || return 1 | |
| 181 | + | if ! curl -fsSL "${GIST_RAW_BASE%/}/config.sh" -o "$tmp" || | |
| 182 | + | [[ ! -s "$tmp" ]] || ! bash -n "$tmp"; then | |
| 203 | 183 | rm -f "$tmp" | |
| 204 | - | warn "Failed to download $remote_file" | |
| 184 | + | err "Failed to load the shared configuration installer" | |
| 185 | + | return 1 | |
| 205 | 186 | fi | |
| 206 | - | done | |
| 207 | - | ||
| 208 | - | ok "Configs downloaded" | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | configure_zshrc() { | |
| 212 | - | local zshrc="$HOME/.zshrc" | |
| 213 | - | log "Configuring .zshrc for Oh My Zsh and Starship..." | |
| 214 | - | ||
| 215 | - | cat > "$zshrc" <<'EOF' | |
| 216 | - | # ============================================================================= | |
| 217 | - | # 1. HELPER FUNCTIONS & PATH | |
| 218 | - | # ============================================================================= | |
| 219 | - | source_if_readable() { | |
| 220 | - | local file="$1" | |
| 221 | - | if [[ -f "$file" && -r "$file" ]]; then | |
| 222 | - | source "$file" | |
| 187 | + | source "$tmp" | |
| 188 | + | rm -f "$tmp" | |
| 223 | 189 | fi | |
| 224 | 190 | } | |
| 225 | 191 | ||
| 226 | - | export PATH="$HOME/.local/bin:$PATH" | |
| 227 | - | ||
| 228 | - | # ============================================================================= | |
| 229 | - | # 2. OH MY ZSH FRAMEWORK | |
| 230 | - | # ============================================================================= | |
| 231 | - | export ZSH="${ZSH:-$HOME/.oh-my-zsh}" | |
| 232 | - | ZSH_THEME="" | |
| 233 | - | ||
| 234 | - | plugins=( | |
| 235 | - | git | |
| 236 | - | zsh-autosuggestions | |
| 237 | - | zsh-syntax-highlighting | |
| 238 | - | ) | |
| 239 | - | ||
| 240 | - | source_if_readable "$ZSH/oh-my-zsh.sh" | |
| 241 | - | ||
| 242 | - | # ============================================================================= | |
| 243 | - | # 3. THIRD-PARTY INITIALIZATION & CUSTOM CONFIGS | |
| 244 | - | # ============================================================================= | |
| 245 | - | source_if_readable "$HOME/.sourcerc" | |
| 246 | - | source_if_readable "$HOME/.func" | |
| 247 | - | source_if_readable "$HOME/.pathrc" | |
| 248 | - | source_if_readable "$HOME/.alias" | |
| 249 | - | ||
| 250 | - | # ============================================================================= | |
| 251 | - | # 4. STARSHIP PROMPT | |
| 252 | - | # ============================================================================= | |
| 253 | - | if [[ -z "${STARSHIP_CONFIG:-}" && -f "$HOME/.config/starship.toml" ]]; then | |
| 254 | - | export STARSHIP_CONFIG="$HOME/.config/starship.toml" | |
| 255 | - | fi | |
| 256 | - | ||
| 257 | - | if command -v starship >/dev/null 2>&1; then | |
| 258 | - | eval "$(starship init zsh)" | |
| 259 | - | elif [[ -x "$HOME/.local/bin/starship" ]]; then | |
| 260 | - | eval "$("$HOME/.local/bin/starship" init zsh)" | |
| 261 | - | fi | |
| 262 | - | EOF | |
| 263 | - | ||
| 264 | - | ok ".zshrc configured for Oh My Zsh framework with Starship prompt" | |
| 265 | - | } | |
| 266 | - | ||
| 267 | 192 | configure_git_identity() { | |
| 268 | 193 | log "Configuring global Git identity..." | |
| 269 | 194 | git config --global user.name "Vernon Wee Hong KOH" | |
| @@ -357,6 +282,7 @@ run_choices() { | |||
| 357 | 282 | # ============================= | |
| 358 | 283 | main() { | |
| 359 | 284 | check_requirements | |
| 285 | + | load_config_installer | |
| 360 | 286 | while true; do | |
| 361 | 287 | show_menu | |
| 362 | 288 | run_choices | |
| @@ -366,4 +292,6 @@ main() { | |||
| 366 | 292 | ok "WSL Zsh installation/configuration complete!" | |
| 367 | 293 | } | |
| 368 | 294 | ||
| 369 | - | main "$@" | |
| 295 | + | if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then | |
| 296 | + | main "$@" | |
| 297 | + | fi | |
weehong ha revisionato questo gist 1 day ago. Vai alla revisione
1 file changed, 14 insertions, 1 deletion
func
| @@ -1134,6 +1134,7 @@ aicommit() { | |||
| 1134 | 1134 | local assume_yes=0 | |
| 1135 | 1135 | local arg | |
| 1136 | 1136 | local diff | |
| 1137 | + | local single_hint | |
| 1137 | 1138 | local staged_count | |
| 1138 | 1139 | local prompt_file="$HOME/.config/ai-commit-prompt.txt" | |
| 1139 | 1140 | ||
| @@ -1189,9 +1190,21 @@ aicommit() { | |||
| 1189 | 1190 | return 1 | |
| 1190 | 1191 | fi | |
| 1191 | 1192 | ||
| 1193 | + | # Above 30 paths a single commit is the user's explicit choice; tell the AI so | |
| 1194 | + | # it does not refuse under the prompt's own split rule. | |
| 1195 | + | single_hint="" | |
| 1196 | + | if [ "$staged_count" -gt 30 ]; then | |
| 1197 | + | if [ "$single" -eq 1 ]; then | |
| 1198 | + | printf "📦 Committing all %s staged paths as a single commit.\n" "$staged_count" >&2 | |
| 1199 | + | fi | |
| 1200 | + | single_hint="USER OVERRIDE: The user explicitly chose ONE commit for all $staged_count staged paths. This overrides any instruction above about splitting commits above 30 paths or by module. Do not refuse, do not suggest --modules, and do not ask for confirmation. Create exactly one commit covering everything staged, with a summary line for the overall change and a body that groups the changes by area. | |
| 1201 | + | ||
| 1202 | + | " | |
| 1203 | + | fi | |
| 1204 | + | ||
| 1192 | 1205 | _aicommit_engine "$engine" commit "$(cat "$prompt_file") | |
| 1193 | 1206 | ||
| 1194 | - | Staged path count: $staged_count | |
| 1207 | + | ${single_hint}Staged path count: $staged_count | |
| 1195 | 1208 | ||
| 1196 | 1209 | Here is the git diff: | |
| 1197 | 1210 | $diff" | |
weehong ha revisionato questo gist 1 day ago. Vai alla revisione
1 file changed, 40 insertions, 6 deletions
func
| @@ -733,9 +733,30 @@ plan_build() { | |||
| 733 | 733 | # Internal: _aicommit_usage (Print aicommit usage) | |
| 734 | 734 | # ----------------------------------------------------------------------------- | |
| 735 | 735 | _aicommit_usage() { | |
| 736 | - | printf "Usage: aicommit [--modules] [--yes] [claude | copilot | opencode]\n" | |
| 737 | - | printf " --modules Force per-module commits (automatic above 30 staged paths)\n" | |
| 738 | - | printf " --yes Skip the plan confirmation for per-module commits\n" | |
| 736 | + | printf "Usage: aicommit [--modules | --single] [--yes] [claude | copilot | opencode]\n" | |
| 737 | + | printf " --modules Split into per module commits without asking\n" | |
| 738 | + | printf " --single Make one commit without asking, even above 30 staged paths\n" | |
| 739 | + | printf " --yes Accept defaults: split above 30 paths and skip the plan confirmation\n" | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | # ----------------------------------------------------------------------------- | |
| 743 | + | # Internal: _aicommit_ask_modules (Ask whether to split above 30 staged paths) | |
| 744 | + | # ----------------------------------------------------------------------------- | |
| 745 | + | _aicommit_ask_modules() { | |
| 746 | + | local staged_count="$1" | |
| 747 | + | local reply | |
| 748 | + | ||
| 749 | + | if ! (: </dev/tty) 2>/dev/null; then | |
| 750 | + | printf "🧩 %s staged paths exceed 30 and no terminal is available; splitting by module.\n" "$staged_count" >&2 | |
| 751 | + | return 0 | |
| 752 | + | fi | |
| 753 | + | ||
| 754 | + | printf "🧩 %s staged paths exceed 30. Split into per module commits? (Y/n) " "$staged_count" >/dev/tty | |
| 755 | + | IFS= read -r reply </dev/tty || reply="" | |
| 756 | + | case "$reply" in | |
| 757 | + | ""|y|Y|yes|YES|Yes) return 0 ;; | |
| 758 | + | *) return 1 ;; | |
| 759 | + | esac | |
| 739 | 760 | } | |
| 740 | 761 | ||
| 741 | 762 | # ----------------------------------------------------------------------------- | |
| @@ -1109,6 +1130,7 @@ _aicommit_modules() { | |||
| 1109 | 1130 | aicommit() { | |
| 1110 | 1131 | local engine="claude" | |
| 1111 | 1132 | local modules=0 | |
| 1133 | + | local single=0 | |
| 1112 | 1134 | local assume_yes=0 | |
| 1113 | 1135 | local arg | |
| 1114 | 1136 | local diff | |
| @@ -1119,6 +1141,7 @@ aicommit() { | |||
| 1119 | 1141 | case "$arg" in | |
| 1120 | 1142 | claude|copilot|opencode) engine="$arg" ;; | |
| 1121 | 1143 | --modules) modules=1 ;; | |
| 1144 | + | --single) single=1 ;; | |
| 1122 | 1145 | -y|--yes) assume_yes=1 ;; | |
| 1123 | 1146 | -h|--help) | |
| 1124 | 1147 | _aicommit_usage | |
| @@ -1132,6 +1155,11 @@ aicommit() { | |||
| 1132 | 1155 | esac | |
| 1133 | 1156 | done | |
| 1134 | 1157 | ||
| 1158 | + | if [ "$modules" -eq 1 ] && [ "$single" -eq 1 ]; then | |
| 1159 | + | printf "❌ Error: --modules and --single cannot be used together.\n" >&2 | |
| 1160 | + | return 1 | |
| 1161 | + | fi | |
| 1162 | + | ||
| 1135 | 1163 | if [ ! -f "$prompt_file" ]; then | |
| 1136 | 1164 | printf "❌ Error: Configuration file not found at ~/.config/ai-commit-prompt.txt\n" >&2 | |
| 1137 | 1165 | printf "Please create it and paste your system prompt inside.\n" >&2 | |
| @@ -1141,10 +1169,16 @@ aicommit() { | |||
| 1141 | 1169 | # Count the full index, including excluded previews and both sides of renames. | |
| 1142 | 1170 | # NUL delimiters keep whitespace/newlines in filenames from skewing the count. | |
| 1143 | 1171 | staged_count="$(set -o pipefail; git diff --cached --name-only --no-renames -z | tr -cd '\0' | wc -c)" || return 1 | |
| 1144 | - | if [ "$staged_count" -gt 30 ] && [ "$modules" -ne 1 ]; then | |
| 1145 | - | printf "🧩 %s staged paths exceed 30; splitting commits by module.\n" "$staged_count" >&2 | |
| 1172 | + | ||
| 1173 | + | if [ "$modules" -ne 1 ] && [ "$single" -ne 1 ] && [ "$staged_count" -gt 30 ]; then | |
| 1174 | + | if [ "$assume_yes" -eq 1 ] || _aicommit_ask_modules "$staged_count"; then | |
| 1175 | + | modules=1 | |
| 1176 | + | else | |
| 1177 | + | printf "📦 Committing all %s staged paths as a single commit.\n" "$staged_count" >&2 | |
| 1178 | + | fi | |
| 1146 | 1179 | fi | |
| 1147 | - | if [ "$modules" -eq 1 ] || [ "$staged_count" -gt 30 ]; then | |
| 1180 | + | ||
| 1181 | + | if [ "$modules" -eq 1 ]; then | |
| 1148 | 1182 | _aicommit_modules "$engine" "$assume_yes" | |
| 1149 | 1183 | return | |
| 1150 | 1184 | fi | |
Vernon Wee Hong KOH ha revisionato questo gist 3 days ago. Vai alla revisione
1 file changed, 12 insertions, 5 deletions
func
| @@ -147,17 +147,24 @@ ch() { | |||
| 147 | 147 | opencode) | |
| 148 | 148 | local opencode_state_home="${XDG_STATE_HOME:-$HOME/.local/state}/opencode" | |
| 149 | 149 | local opencode_data_home="${XDG_DATA_HOME:-$HOME/.local/share}/opencode" | |
| 150 | + | local opencode_db="${OPENCODE_DB:-$opencode_data_home/opencode.db}" | |
| 151 | + | ||
| 152 | + | require_cli opencode "OpenCode CLI" || return 1 | |
| 153 | + | # Close OpenCode clients before running this from a separate shell. | |
| 154 | + | # Stop the service before unlinking its database and SQLite sidecars. | |
| 155 | + | command opencode service stop || return 1 | |
| 150 | 156 | ||
| 151 | 157 | command mkdir -p -- "$opencode_state_home" "$opencode_data_home" || return 1 | |
| 152 | 158 | : > "$opencode_state_home/prompt-history.jsonl" || return 1 | |
| 153 | 159 | command rm -f -- \ | |
| 154 | - | "$opencode_data_home/opencode.db" \ | |
| 155 | - | "$opencode_data_home/opencode.db-shm" \ | |
| 156 | - | "$opencode_data_home/opencode.db-wal" || return 1 | |
| 160 | + | "$opencode_db" \ | |
| 161 | + | "$opencode_db-shm" \ | |
| 162 | + | "$opencode_db-wal" \ | |
| 163 | + | "$opencode_db-journal" || return 1 | |
| 157 | 164 | command rm -rf -- "$opencode_data_home/repos" "$opencode_data_home/log" "$opencode_data_home/snapshot" || return 1 | |
| 158 | 165 | command mkdir -p -- "$opencode_data_home/repos" "$opencode_data_home/log" "$opencode_data_home/snapshot" || return 1 | |
| 159 | 166 | ||
| 160 | - | echo "OpenCode history cleared." | |
| 167 | + | echo "OpenCode database and history cleared. Launch OpenCode to start fresh." | |
| 161 | 168 | ;; | |
| 162 | 169 | ||
| 163 | 170 | ""|shell) | |
| @@ -1359,4 +1366,4 @@ add_path_to_config() { | |||
| 1359 | 1366 | ||
| 1360 | 1367 | printf "✅ Success: Added to %s\n" "$config_file" | |
| 1361 | 1368 | append_path "$new_path" && export PATH | |
| 1362 | - | } | |
| 1369 | + | } | |
weehong ha revisionato questo gist 6 days ago. Vai alla revisione
2 files changed, 38 insertions, 3 deletions
func
| @@ -739,6 +739,31 @@ _aicommit_staged_diff() { | |||
| 739 | 739 | git diff --cached -- . ":(exclude)*.lock" ":(exclude)*-lock.json" ":(exclude)*.min.js" | head -c 100000 | |
| 740 | 740 | } | |
| 741 | 741 | ||
| 742 | + | # ----------------------------------------------------------------------------- | |
| 743 | + | # Internal: _aicommit_opencode_model (Read OpenCode's most recent model selection) | |
| 744 | + | # ----------------------------------------------------------------------------- | |
| 745 | + | _aicommit_opencode_model() { | |
| 746 | + | python3 - <<'PY' | |
| 747 | + | import json | |
| 748 | + | import os | |
| 749 | + | from pathlib import Path | |
| 750 | + | ||
| 751 | + | state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") | |
| 752 | + | try: | |
| 753 | + | data = json.loads((state / "opencode/model.json").read_text()) | |
| 754 | + | recent = data.get("recent", []) | |
| 755 | + | if recent: | |
| 756 | + | selected = recent[0] | |
| 757 | + | model = selected["providerID"] + "/" + selected["modelID"] | |
| 758 | + | variant = data.get("variant", {}).get(model) | |
| 759 | + | if variant and variant != "default": | |
| 760 | + | model += "#" + variant | |
| 761 | + | print(model) | |
| 762 | + | except FileNotFoundError: | |
| 763 | + | pass # Let OpenCode use its configured default on a fresh installation. | |
| 764 | + | PY | |
| 765 | + | } | |
| 766 | + | ||
| 742 | 767 | # ----------------------------------------------------------------------------- | |
| 743 | 768 | # Internal: _aicommit_engine (Ask an AI CLI to commit or to plan a split) | |
| 744 | 769 | # ----------------------------------------------------------------------------- | |
| @@ -747,6 +772,16 @@ _aicommit_engine() { | |||
| 747 | 772 | local mode="$2" | |
| 748 | 773 | local input="$3" | |
| 749 | 774 | local name | |
| 775 | + | local selected_model | |
| 776 | + | local -a model_args | |
| 777 | + | model_args=() | |
| 778 | + | ||
| 779 | + | if [ "$engine" = "opencode" ]; then | |
| 780 | + | selected_model="$(_aicommit_opencode_model)" || return 1 | |
| 781 | + | if [ -n "$selected_model" ]; then | |
| 782 | + | model_args=(--model "$selected_model") | |
| 783 | + | fi | |
| 784 | + | fi | |
| 750 | 785 | ||
| 751 | 786 | case "$engine" in | |
| 752 | 787 | claude) name="Claude" ;; | |
| @@ -779,10 +814,10 @@ _aicommit_engine() { | |||
| 779 | 814 | copilot -p "$input" -s --no-ask-user | |
| 780 | 815 | ;; | |
| 781 | 816 | opencode:commit) | |
| 782 | - | printf "%s\n" "$input" | opencode run --agent ai-commit | |
| 817 | + | printf "%s\n" "$input" | opencode run --agent ai-commit "${model_args[@]}" | |
| 783 | 818 | ;; | |
| 784 | 819 | opencode:plan) | |
| 785 | - | printf "%s\n" "$input" | opencode run --agent ai-commit-plan | |
| 820 | + | printf "%s\n" "$input" | opencode run --agent ai-commit-plan "${model_args[@]}" | |
| 786 | 821 | ;; | |
| 787 | 822 | esac | |
| 788 | 823 | } | |
| @@ -1324,4 +1359,4 @@ add_path_to_config() { | |||
| 1324 | 1359 | ||
| 1325 | 1360 | printf "✅ Success: Added to %s\n" "$config_file" | |
| 1326 | 1361 | append_path "$new_path" && export PATH | |
| 1327 | - | } | |
| 1362 | + | } | |
File vuoto
Vernon Wee Hong KOH ha revisionato questo gist 1 week ago. Vai alla revisione
1 file changed, 4 insertions
func
| @@ -176,6 +176,10 @@ ch() { | |||
| 176 | 176 | return 2 | |
| 177 | 177 | ;; | |
| 178 | 178 | esac | |
| 179 | + | ||
| 180 | + | if [ -t 1 ]; then | |
| 181 | + | command clear | |
| 182 | + | fi | |
| 179 | 183 | } | |
| 180 | 184 | ||
| 181 | 185 | # ----------------------------------------------------------------------------- | |
Vernon Wee Hong KOH ha revisionato questo gist 1 week ago. Vai alla revisione
7 files changed, 192 insertions, 7 deletions
README.md
| @@ -41,13 +41,15 @@ aicommit opencode | |||
| 41 | 41 | ||
| 42 | 42 | The installers place the shared commit instructions at `~/.config/ai-commit-prompt.txt` and the required OpenCode agent at `~/.config/opencode/agents/ai-commit.md`. The agent is hidden from the interactive agent selector but remains available to `aicommit opencode`. It may inspect Git status, the cached diff, and recent history, and may execute `git commit`; all unrelated tools and shell commands are denied. | |
| 43 | 43 | ||
| 44 | - | Pass `--modules` to split a large staged change into one commit per module instead: | |
| 44 | + | **Changes affecting more than 30 staged paths automatically use per-module commits.** They must produce multiple commits, rather than a single combined commit message. The count uses the full staged set before diff filtering or truncation, including lock/minified files and both paths of a rename. Exactly 30 paths retains the single-commit default. Pass `--modules` to request splitting at any size: | |
| 45 | 45 | ||
| 46 | 46 | ```zsh | |
| 47 | 47 | aicommit --modules opencode | |
| 48 | 48 | aicommit --modules --yes claude | |
| 49 | 49 | ``` | |
| 50 | 50 | ||
| 51 | + | Automatic splitting uses the same planning and confirmation flow as `--modules`; `--yes` skips confirmation in either case (for example, `aicommit --yes opencode`). Plans that collapse a change above the threshold into one group are rejected before committing, with the index untouched. Grouping follows cohesive modules or submodules; 30 is the automatic-splitting threshold, not a hard size limit per planned module. | |
| 52 | + | ||
| 51 | 53 | The engine first plans the split using `~/.config/ai-commit-split-prompt.txt` and replies with a JSON plan that maps every staged path to a `type(scope)` group, ordered so dependencies land first. The function validates the plan against the staged paths, ignores paths that are not staged, appends any unplanned paths as a final commit, and shows the plan for confirmation (`--yes` skips it). It then stages each group from a snapshot of the original index, so partially staged files keep only their staged hunks and unstaged or untracked work is never touched, and asks the engine to commit that group with the normal commit instructions. If a group produces no commit, or a commit includes files outside its group, the run stops and restages the remaining groups. With OpenCode, planning uses the hidden `ai-commit-plan` agent at `~/.config/opencode/agents/ai-commit-plan.md`, which is denied every tool. Python 3 is required to parse the plan. | |
| 52 | 54 | ||
| 53 | 55 | ## Plan-build workflow | |
ai-commit-plan.md
| @@ -7,3 +7,5 @@ permission: | |||
| 7 | 7 | --- | |
| 8 | 8 | ||
| 9 | 9 | Follow the supplied planning instructions and staged file list. Reply with only the JSON plan. Do not run commands, modify files, or change the staged set. | |
| 10 | + | ||
| 11 | + | Changes affecting more than 30 staged paths must be modularized into at least two non-empty commits; never return a single combined group. Count the full staged file list, including lock/minified files and both paths of a rename. Split a large module into cohesive, independently reviewable submodules. At 30 paths or fewer, a single group is allowed if the changes form one module. This threshold is not a hard file limit per planned module. | |
ai-commit-prompt.txt
| @@ -18,6 +18,18 @@ git diff --cached | |||
| 18 | 18 | ||
| 19 | 19 | ## Strict Rules (Zero Tolerance) | |
| 20 | 20 | ||
| 21 | + | ### Mandatory Module Splitting | |
| 22 | + | Changes affecting more than 30 staged paths MUST be modularized into | |
| 23 | + | multiple commits. Do not generate or execute a single combined commit for | |
| 24 | + | them. Count the full staged set, including lock/minified files omitted from | |
| 25 | + | the diff preview and both paths of a rename, not just the visible diff. | |
| 26 | + | If this input has more than 30 staged paths and is not explicitly identified | |
| 27 | + | as one module in a multi-commit plan, stop and request `aicommit --modules`. | |
| 28 | + | This refusal takes precedence over the output and execution instructions below. | |
| 29 | + | When the input says "commit X of Y, split by module" with Y greater than 1, | |
| 30 | + | generate and execute only that module's commit. The threshold applies to | |
| 31 | + | the original staged change, not a hard file limit per planned module. | |
| 32 | + | ||
| 21 | 33 | ### 1. Header Format | |
| 22 | 34 | **Pattern:** `type(scope): subject` | |
| 23 | 35 | ||
ai-commit-split-prompt.txt
| @@ -49,8 +49,13 @@ to a single Conventional Commit `type(scope)`. Typical modules: | |||
| 49 | 49 | 6. Each commit should have ONE type and ONE scope. If a group would need two | |
| 50 | 50 | types (e.g. `feat` and `docs`), split it. | |
| 51 | 51 | 7. Do not over-split. A module normally spans several files; do not create | |
| 52 | - | one commit per file unless each file really is its own module. If all the | |
| 53 | - | changes form one module, return a single commit. | |
| 52 | + | one commit per file unless each file really is its own module. Changes | |
| 53 | + | affecting more than 30 staged paths MUST be modularized into at least two | |
| 54 | + | non-empty commits; never return a single combined commit for them. Count | |
| 55 | + | all staged paths, including lock/minified files and both paths of a rename. | |
| 56 | + | Split a large module into cohesive, independently reviewable submodules. | |
| 57 | + | At 30 paths or fewer, a single commit is allowed if all changes form one | |
| 58 | + | module. This threshold is not a hard file limit per planned module. | |
| 54 | 59 | 8. Order commits so dependencies land first: | |
| 55 | 60 | build/deps -> shared code (ui, i18n, lib, types) -> features that use | |
| 56 | 61 | them -> tests -> stories/docs. | |
ai-commit.md
| @@ -12,4 +12,6 @@ permission: | |||
| 12 | 12 | "git commit *": allow | |
| 13 | 13 | --- | |
| 14 | 14 | ||
| 15 | - | Follow the supplied commit instructions and staged diff. Generate a concise conventional commit message, then execute exactly one `git commit` with that message. You may inspect Git status, the cached diff, and recent log. Do not modify files or change the staged set. | |
| 15 | + | Follow the supplied commit instructions and staged diff. Changes affecting more than 30 staged paths must be modularized into multiple commits; never emit or execute a single combined commit for them. Use the supplied full staged path count, or inspect `git diff --cached --name-only --no-renames` if it is absent, including files omitted from the diff preview. Unless the input explicitly identifies this as one module in a multi-commit plan ("commit X of Y, split by module", Y > 1), stop and request `aicommit --modules` when the count exceeds 30. This refusal takes precedence over commit execution. | |
| 16 | + | ||
| 17 | + | For an eligible staged change or an individual module from that plan, generate a concise conventional commit message, then execute exactly one `git commit` with that message. You may inspect Git status, the cached diff, and recent log. Do not modify files or change the staged set. | |
func
| @@ -723,8 +723,8 @@ plan_build() { | |||
| 723 | 723 | # ----------------------------------------------------------------------------- | |
| 724 | 724 | _aicommit_usage() { | |
| 725 | 725 | printf "Usage: aicommit [--modules] [--yes] [claude | copilot | opencode]\n" | |
| 726 | - | printf " --modules Split staged changes into one commit per module\n" | |
| 727 | - | printf " --yes Skip the plan confirmation in --modules mode\n" | |
| 726 | + | printf " --modules Force per-module commits (automatic above 30 staged paths)\n" | |
| 727 | + | printf " --yes Skip the plan confirmation for per-module commits\n" | |
| 728 | 728 | } | |
| 729 | 729 | ||
| 730 | 730 | # ----------------------------------------------------------------------------- | |
| @@ -874,6 +874,9 @@ if missing: | |||
| 874 | 874 | warnings.append("%d staged path(s) were not planned; adding them as a final commit" % len(missing)) | |
| 875 | 875 | groups.append(("", missing)) | |
| 876 | 876 | ||
| 877 | + | if len(staged) > 30 and len(groups) < 2: | |
| 878 | + | sys.exit("❌ Error: Changes affecting more than 30 staged paths require multiple module commits.") | |
| 879 | + | ||
| 877 | 880 | for number, (label, files) in enumerate(groups, 1): | |
| 878 | 881 | base = os.path.join(run_dir, "%03d" % number) | |
| 879 | 882 | with open(base + ".files", "wb") as fh: | |
| @@ -1063,6 +1066,7 @@ aicommit() { | |||
| 1063 | 1066 | local assume_yes=0 | |
| 1064 | 1067 | local arg | |
| 1065 | 1068 | local diff | |
| 1069 | + | local staged_count | |
| 1066 | 1070 | local prompt_file="$HOME/.config/ai-commit-prompt.txt" | |
| 1067 | 1071 | ||
| 1068 | 1072 | for arg in "$@"; do | |
| @@ -1088,7 +1092,13 @@ aicommit() { | |||
| 1088 | 1092 | return 1 | |
| 1089 | 1093 | fi | |
| 1090 | 1094 | ||
| 1091 | - | if [ "$modules" -eq 1 ]; then | |
| 1095 | + | # Count the full index, including excluded previews and both sides of renames. | |
| 1096 | + | # NUL delimiters keep whitespace/newlines in filenames from skewing the count. | |
| 1097 | + | staged_count="$(set -o pipefail; git diff --cached --name-only --no-renames -z | tr -cd '\0' | wc -c)" || return 1 | |
| 1098 | + | if [ "$staged_count" -gt 30 ] && [ "$modules" -ne 1 ]; then | |
| 1099 | + | printf "🧩 %s staged paths exceed 30; splitting commits by module.\n" "$staged_count" >&2 | |
| 1100 | + | fi | |
| 1101 | + | if [ "$modules" -eq 1 ] || [ "$staged_count" -gt 30 ]; then | |
| 1092 | 1102 | _aicommit_modules "$engine" "$assume_yes" | |
| 1093 | 1103 | return | |
| 1094 | 1104 | fi | |
| @@ -1101,6 +1111,8 @@ aicommit() { | |||
| 1101 | 1111 | ||
| 1102 | 1112 | _aicommit_engine "$engine" commit "$(cat "$prompt_file") | |
| 1103 | 1113 | ||
| 1114 | + | Staged path count: $staged_count | |
| 1115 | + | ||
| 1104 | 1116 | Here is the git diff: | |
| 1105 | 1117 | $diff" | |
| 1106 | 1118 | } | |
test_aicommit.py(file creato)
| @@ -0,0 +1,150 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """Exercise aicommit routing and plan validation without invoking an AI CLI.""" | |
| 3 | + | ||
| 4 | + | import json | |
| 5 | + | import os | |
| 6 | + | from pathlib import Path | |
| 7 | + | import subprocess | |
| 8 | + | import tempfile | |
| 9 | + | import unittest | |
| 10 | + | ||
| 11 | + | ||
| 12 | + | SOURCE = Path(__file__).resolve().parent / "func" | |
| 13 | + | ROUTE = ''' | |
| 14 | + | source "$1" | |
| 15 | + | shift | |
| 16 | + | _aicommit_engine() { printf 'ENGINE %s %s\n' "$1" "$2"; } | |
| 17 | + | _aicommit_modules() { printf 'MODULES %s %s\n' "$1" "$2"; return 7; } | |
| 18 | + | aicommit "$@" | |
| 19 | + | ''' | |
| 20 | + | ||
| 21 | + | ||
| 22 | + | class AicommitTests(unittest.TestCase): | |
| 23 | + | def setUp(self): | |
| 24 | + | self.temp = tempfile.TemporaryDirectory(prefix="aicommit-test-") | |
| 25 | + | self.addCleanup(self.temp.cleanup) | |
| 26 | + | self.root = Path(self.temp.name) | |
| 27 | + | self.repo = self.root / "repo" | |
| 28 | + | self.repo.mkdir() | |
| 29 | + | self.home = self.root / "home" | |
| 30 | + | config = self.home / ".config" | |
| 31 | + | config.mkdir(parents=True) | |
| 32 | + | for name in ("ai-commit-prompt.txt", "ai-commit-split-prompt.txt"): | |
| 33 | + | (config / name).write_text((SOURCE.parent / name).read_text()) | |
| 34 | + | self.env = {**os.environ, "HOME": str(self.home)} | |
| 35 | + | self.git("init", "-q") | |
| 36 | + | ||
| 37 | + | def git(self, *args): | |
| 38 | + | return subprocess.check_output( | |
| 39 | + | ["git", *args], cwd=self.repo, env=self.env | |
| 40 | + | ) | |
| 41 | + | ||
| 42 | + | def shell(self, script, *args, cwd=None): | |
| 43 | + | return subprocess.run( | |
| 44 | + | ["zsh", "-f", "-c", script, "test", str(SOURCE), *args], | |
| 45 | + | cwd=cwd or self.repo, env=self.env, text=True, capture_output=True, | |
| 46 | + | ) | |
| 47 | + | ||
| 48 | + | def stage(self, count, suffix=".txt"): | |
| 49 | + | # A newline is part of one path, not an extra staged file. | |
| 50 | + | files = [f"file {i}{suffix}" for i in range(count)] | |
| 51 | + | if files: | |
| 52 | + | files[0] = "line\nbreak" + suffix | |
| 53 | + | for name in files: | |
| 54 | + | (self.repo / name).write_text("staged content\n") | |
| 55 | + | self.git("add", "--all") | |
| 56 | + | return files | |
| 57 | + | ||
| 58 | + | def test_thirty_paths_use_single_commit(self): | |
| 59 | + | self.stage(30) | |
| 60 | + | result = self.shell(ROUTE, "opencode") | |
| 61 | + | self.assertEqual(result.returncode, 0, result.stderr) | |
| 62 | + | self.assertEqual(result.stdout.strip(), "ENGINE opencode commit") | |
| 63 | + | ||
| 64 | + | def test_thirty_one_paths_route_all_engines_and_forward_yes(self): | |
| 65 | + | self.stage(31) | |
| 66 | + | for engine in ("claude", "copilot", "opencode"): | |
| 67 | + | with self.subTest(engine=engine): | |
| 68 | + | result = self.shell(ROUTE, "--yes", engine) | |
| 69 | + | self.assertEqual(result.returncode, 7, result.stderr) | |
| 70 | + | self.assertEqual(result.stdout.strip(), f"MODULES {engine} 1") | |
| 71 | + | self.assertIn("exceed 30", result.stderr) | |
| 72 | + | ||
| 73 | + | def test_explicit_modules_below_threshold(self): | |
| 74 | + | self.stage(1) | |
| 75 | + | result = self.shell(ROUTE, "--modules", "copilot") | |
| 76 | + | self.assertEqual(result.returncode, 7, result.stderr) | |
| 77 | + | self.assertEqual(result.stdout.strip(), "MODULES copilot 0") | |
| 78 | + | ||
| 79 | + | def test_filtered_files_still_trigger_modules_from_subdirectory(self): | |
| 80 | + | self.stage(31, ".lock") | |
| 81 | + | nested = self.repo / "nested" | |
| 82 | + | nested.mkdir() | |
| 83 | + | result = self.shell(ROUTE, cwd=nested) | |
| 84 | + | self.assertEqual(result.returncode, 7, result.stderr) | |
| 85 | + | self.assertEqual(result.stdout.strip(), "MODULES claude 0") | |
| 86 | + | ||
| 87 | + | def test_empty_index_and_git_errors_never_call_engine(self): | |
| 88 | + | for cwd in (self.repo, self.root): | |
| 89 | + | with self.subTest(cwd=cwd): | |
| 90 | + | result = self.shell(ROUTE, cwd=cwd) | |
| 91 | + | self.assertNotEqual(result.returncode, 0) | |
| 92 | + | self.assertNotIn("ENGINE", result.stdout) | |
| 93 | + | self.assertNotIn("MODULES", result.stdout) | |
| 94 | + | ||
| 95 | + | def plan(self, count, groups): | |
| 96 | + | run = self.root / "plan" | |
| 97 | + | run.mkdir() | |
| 98 | + | files = [f"file-{i}" for i in range(count)] | |
| 99 | + | (run / "staged").write_bytes(b"".join(p.encode() + b"\0" for p in files)) | |
| 100 | + | (run / "plan.out").write_text(json.dumps({"commits": [ | |
| 101 | + | {"type": "feat", "scope": "test", "files": group(files)} | |
| 102 | + | for group in groups | |
| 103 | + | ]})) | |
| 104 | + | return self.shell('source "$1"; _aicommit_plan "$2"', str(run)), run | |
| 105 | + | ||
| 106 | + | def test_single_group_plan_allowed_at_thirty(self): | |
| 107 | + | result, run = self.plan(30, [lambda files: files]) | |
| 108 | + | self.assertEqual(result.returncode, 0, result.stderr) | |
| 109 | + | self.assertEqual((run / "count").read_text(), "1") | |
| 110 | + | ||
| 111 | + | def test_single_group_plan_rejected_above_thirty_after_deduplication(self): | |
| 112 | + | result, run = self.plan(31, [lambda files: files, lambda files: files]) | |
| 113 | + | self.assertNotEqual(result.returncode, 0) | |
| 114 | + | self.assertIn("require multiple module commits", result.stderr) | |
| 115 | + | self.assertFalse((run / "count").exists()) | |
| 116 | + | ||
| 117 | + | def test_empty_plan_cannot_fall_back_to_one_large_commit(self): | |
| 118 | + | result, _ = self.plan(31, []) | |
| 119 | + | self.assertNotEqual(result.returncode, 0) | |
| 120 | + | self.assertIn("require multiple module commits", result.stderr) | |
| 121 | + | ||
| 122 | + | def test_multiple_groups_allowed_even_with_large_module(self): | |
| 123 | + | result, run = self.plan(32, [lambda files: files[:-1], lambda files: files[-1:]]) | |
| 124 | + | self.assertEqual(result.returncode, 0, result.stderr) | |
| 125 | + | self.assertEqual((run / "count").read_text(), "2") | |
| 126 | + | ||
| 127 | + | def test_rejected_auto_plan_preserves_partial_index_and_worktree(self): | |
| 128 | + | files = self.stage(31) | |
| 129 | + | changed = self.repo / files[0] | |
| 130 | + | changed.write_text("unstaged content\n") | |
| 131 | + | before = self.git("write-tree") | |
| 132 | + | plan = self.root / "single-plan.json" | |
| 133 | + | plan.write_text(json.dumps({"commits": [{"files": files}]})) | |
| 134 | + | result = self.shell(''' | |
| 135 | + | source "$1" | |
| 136 | + | plan_file="$2" | |
| 137 | + | _aicommit_engine() { | |
| 138 | + | if [ "$2" = plan ]; then cat "$plan_file"; else printf 'UNEXPECTED COMMIT\n'; fi | |
| 139 | + | } | |
| 140 | + | aicommit --yes opencode | |
| 141 | + | ''', str(plan)) | |
| 142 | + | self.assertNotEqual(result.returncode, 0) | |
| 143 | + | self.assertIn("require multiple module commits", result.stderr) | |
| 144 | + | self.assertNotIn("UNEXPECTED COMMIT", result.stdout) | |
| 145 | + | self.assertEqual(self.git("write-tree"), before) | |
| 146 | + | self.assertEqual(changed.read_text(), "unstaged content\n") | |
| 147 | + | ||
| 148 | + | ||
| 149 | + | if __name__ == "__main__": | |
| 150 | + | unittest.main() | |
Vernon Wee Hong KOH ha revisionato questo gist 2 weeks ago. Vai alla revisione
8 files changed, 492 insertions, 40 deletions
README.md
| @@ -33,7 +33,7 @@ Starship owns the prompt and loads after Oh My Zsh. The managed Starship config | |||
| 33 | 33 | ||
| 34 | 34 | ## AI commits | |
| 35 | 35 | ||
| 36 | - | The managed `aicommit` function can use Claude, Codex, Copilot, or OpenCode to create a commit from staged changes: | |
| 36 | + | The managed `aicommit` function can use Claude, Copilot, or OpenCode to create a commit from staged changes: | |
| 37 | 37 | ||
| 38 | 38 | ```zsh | |
| 39 | 39 | aicommit opencode | |
| @@ -41,6 +41,15 @@ aicommit opencode | |||
| 41 | 41 | ||
| 42 | 42 | The installers place the shared commit instructions at `~/.config/ai-commit-prompt.txt` and the required OpenCode agent at `~/.config/opencode/agents/ai-commit.md`. The agent is hidden from the interactive agent selector but remains available to `aicommit opencode`. It may inspect Git status, the cached diff, and recent history, and may execute `git commit`; all unrelated tools and shell commands are denied. | |
| 43 | 43 | ||
| 44 | + | Pass `--modules` to split a large staged change into one commit per module instead: | |
| 45 | + | ||
| 46 | + | ```zsh | |
| 47 | + | aicommit --modules opencode | |
| 48 | + | aicommit --modules --yes claude | |
| 49 | + | ``` | |
| 50 | + | ||
| 51 | + | The engine first plans the split using `~/.config/ai-commit-split-prompt.txt` and replies with a JSON plan that maps every staged path to a `type(scope)` group, ordered so dependencies land first. The function validates the plan against the staged paths, ignores paths that are not staged, appends any unplanned paths as a final commit, and shows the plan for confirmation (`--yes` skips it). It then stages each group from a snapshot of the original index, so partially staged files keep only their staged hunks and unstaged or untracked work is never touched, and asks the engine to commit that group with the normal commit instructions. If a group produces no commit, or a commit includes files outside its group, the run stops and restages the remaining groups. With OpenCode, planning uses the hidden `ai-commit-plan` agent at `~/.config/opencode/agents/ai-commit-plan.md`, which is denied every tool. Python 3 is required to parse the plan. | |
| 52 | + | ||
| 44 | 53 | ## Plan-build workflow | |
| 45 | 54 | ||
| 46 | 55 | The local `plan_build` function is a thin cached launcher for the canonical public [plan-build gist](https://opengist.resetrix.work/weehong/plan-build). When curl is available, each invocation stages `plan_build.zsh`, `SKILL.md`, and `ARCHITECT.md`, requires all three to be non-empty, and syntax-checks the executable. It then moves the complete bundle into a unique immutable release directory and atomically switches one `current` symlink. Refresh activation is serialized with a kernel-backed Zsh file lock so concurrent callers cannot mix releases and crashes cannot leave a stale lock. If refresh fails, the launcher preserves and uses the last complete, validated release and repairs its skill links. Zsh is always required; curl is required only when no valid cached release exists. | |
ai-commit-plan.md(file creato)
| @@ -0,0 +1,9 @@ | |||
| 1 | + | --- | |
| 2 | + | description: Plans how to split the staged diff supplied in the prompt into per-module commits. | |
| 3 | + | mode: primary | |
| 4 | + | hidden: true | |
| 5 | + | permission: | |
| 6 | + | "*": deny | |
| 7 | + | --- | |
| 8 | + | ||
| 9 | + | Follow the supplied planning instructions and staged file list. Reply with only the JSON plan. Do not run commands, modify files, or change the staged set. | |
ai-commit-split-prompt.txt(file creato)
| @@ -0,0 +1,91 @@ | |||
| 1 | + | # Git Commit Split Planner Prompt | |
| 2 | + | ||
| 3 | + | ## Role | |
| 4 | + | You are an Automated Git Commit Planner. | |
| 5 | + | ||
| 6 | + | ## Objective | |
| 7 | + | Split the staged changes into a sequence of small, atomic commits, one per | |
| 8 | + | **module**. You only decide the grouping and order. You do NOT write commit | |
| 9 | + | messages and you do NOT run any commands. | |
| 10 | + | ||
| 11 | + | --- | |
| 12 | + | ||
| 13 | + | ## Input | |
| 14 | + | 1. The staged file list (`git diff --cached --name-status -M`) | |
| 15 | + | 2. A diffstat (`git diff --cached --numstat -M`: added, deleted, path) | |
| 16 | + | 3. The staged diff (lock files and minified files are omitted; it may be truncated) | |
| 17 | + | ||
| 18 | + | --- | |
| 19 | + | ||
| 20 | + | ## What Is a Module | |
| 21 | + | A module is one cohesive, independently reviewable unit of change that maps | |
| 22 | + | to a single Conventional Commit `type(scope)`. Typical modules: | |
| 23 | + | ||
| 24 | + | | Module | Example scope | | |
| 25 | + | | :--- | :--- | | |
| 26 | + | | A feature or domain area (pages, routes, services, models) | `organisation`, `auth` | | |
| 27 | + | | Shared UI components / design system | `ui` | | |
| 28 | + | | Translations / localisation | `i18n` | | |
| 29 | + | | End-to-end or integration tests not tied to one module | `e2e` | | |
| 30 | + | | Storybook stories | `storybook` | | |
| 31 | + | | Documentation | `docs` | | |
| 32 | + | | Build, dependencies, tooling config | `deps`, `eslint` | | |
| 33 | + | | CI/CD pipelines | `ci` | | |
| 34 | + | ||
| 35 | + | --- | |
| 36 | + | ||
| 37 | + | ## Strict Rules (Zero Tolerance) | |
| 38 | + | ||
| 39 | + | 1. Every path in the staged file list MUST appear in exactly one commit. | |
| 40 | + | Copy paths exactly as listed. Never invent, rename, or omit a path. | |
| 41 | + | 2. A rename line (`R<score> old new`) lists two paths. Put BOTH in the same | |
| 42 | + | commit. | |
| 43 | + | 3. Never split a single file across commits. | |
| 44 | + | 4. Lock files go in the same commit as the manifest that changed them | |
| 45 | + | (e.g. `package.json` with `package-lock.json`). | |
| 46 | + | 5. Unit tests that sit next to, or only exercise, one module go in that | |
| 47 | + | module's commit. E2E tests, storybook stories and docs get their own | |
| 48 | + | commits unless they are the only change. | |
| 49 | + | 6. Each commit should have ONE type and ONE scope. If a group would need two | |
| 50 | + | types (e.g. `feat` and `docs`), split it. | |
| 51 | + | 7. Do not over-split. A module normally spans several files; do not create | |
| 52 | + | one commit per file unless each file really is its own module. If all the | |
| 53 | + | changes form one module, return a single commit. | |
| 54 | + | 8. Order commits so dependencies land first: | |
| 55 | + | build/deps -> shared code (ui, i18n, lib, types) -> features that use | |
| 56 | + | them -> tests -> stories/docs. | |
| 57 | + | ||
| 58 | + | --- | |
| 59 | + | ||
| 60 | + | ## Output Format | |
| 61 | + | ||
| 62 | + | Output ONLY one JSON object. No prose, no explanation, no markdown fences. | |
| 63 | + | ||
| 64 | + | ```json | |
| 65 | + | {"commits":[{"type":"feat","scope":"i18n","files":["src/locales/en/organisation.json"]}]} | |
| 66 | + | ``` | |
| 67 | + | ||
| 68 | + | * `type`: one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, | |
| 69 | + | `test`, `build`, `ci`, `chore` | |
| 70 | + | * `scope`: lowercase scope, or an empty string if none fits | |
| 71 | + | * `files`: the exact paths from the staged file list | |
| 72 | + | ||
| 73 | + | --- | |
| 74 | + | ||
| 75 | + | ## Example | |
| 76 | + | ||
| 77 | + | * **Staged files:** `src/locales/en/organisation.json`, | |
| 78 | + | `src/locales/en/settings.json`, `src/components/ui/data-table.tsx`, | |
| 79 | + | `src/components/ui/stat-card.tsx`, `src/app/organisation/page.tsx`, | |
| 80 | + | `src/app/organisation/list/page.tsx`, | |
| 81 | + | `src/components/ui/data-table.stories.tsx`, `e2e/home.spec.ts` | |
| 82 | + | * **Output:** | |
| 83 | + | ```json | |
| 84 | + | {"commits":[ | |
| 85 | + | {"type":"feat","scope":"i18n","files":["src/locales/en/organisation.json","src/locales/en/settings.json"]}, | |
| 86 | + | {"type":"feat","scope":"ui","files":["src/components/ui/data-table.tsx","src/components/ui/stat-card.tsx"]}, | |
| 87 | + | {"type":"feat","scope":"organisation","files":["src/app/organisation/page.tsx","src/app/organisation/list/page.tsx"]}, | |
| 88 | + | {"type":"docs","scope":"storybook","files":["src/components/ui/data-table.stories.tsx"]}, | |
| 89 | + | {"type":"test","scope":"e2e","files":["e2e/home.spec.ts"]} | |
| 90 | + | ]} | |
| 91 | + | ``` | |
config.sh
| @@ -12,7 +12,9 @@ CONFIG_FILES=( | |||
| 12 | 12 | ".zshrc" | |
| 13 | 13 | ".config/starship.toml" | |
| 14 | 14 | ".config/ai-commit-prompt.txt" | |
| 15 | + | ".config/ai-commit-split-prompt.txt" | |
| 15 | 16 | ".config/opencode/agents/ai-commit.md" | |
| 17 | + | ".config/opencode/agents/ai-commit-plan.md" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| 18 | 20 | echo "Starting configuration download..." | |
| @@ -23,7 +25,9 @@ for f in "${CONFIG_FILES[@]}"; do | |||
| 23 | 25 | remote_name="${f#.}" | |
| 24 | 26 | [[ "$f" == ".config/starship.toml" ]] && remote_name="starship.toml" | |
| 25 | 27 | [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_name="ai-commit-prompt.txt" | |
| 28 | + | [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_name="ai-commit-split-prompt.txt" | |
| 26 | 29 | [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_name="ai-commit.md" | |
| 30 | + | [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_name="ai-commit-plan.md" | |
| 27 | 31 | url="$GIST_RAW_BASE/$remote_name" | |
| 28 | 32 | target="$HOME/$f" | |
| 29 | 33 | tmp="${target}.tmp.$$" | |
func
| @@ -719,63 +719,390 @@ plan_build() { | |||
| 719 | 719 | } | |
| 720 | 720 | ||
| 721 | 721 | # ----------------------------------------------------------------------------- | |
| 722 | - | # Function: aicommit (Automated Conventional Commit Engine Wrapper) | |
| 722 | + | # Internal: _aicommit_usage (Print aicommit usage) | |
| 723 | 723 | # ----------------------------------------------------------------------------- | |
| 724 | - | aicommit() { | |
| 725 | - | local diff engine prompt | |
| 726 | - | engine=${1:-claude} | |
| 724 | + | _aicommit_usage() { | |
| 725 | + | printf "Usage: aicommit [--modules] [--yes] [claude | copilot | opencode]\n" | |
| 726 | + | printf " --modules Split staged changes into one commit per module\n" | |
| 727 | + | printf " --yes Skip the plan confirmation in --modules mode\n" | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | # ----------------------------------------------------------------------------- | |
| 731 | + | # Internal: _aicommit_staged_diff (Staged diff without lock/minified files) | |
| 732 | + | # ----------------------------------------------------------------------------- | |
| 733 | + | _aicommit_staged_diff() { | |
| 734 | + | # Exclude lock files/minified files and cap at ~100k characters to prevent CLI crashes | |
| 735 | + | git diff --cached -- . ":(exclude)*.lock" ":(exclude)*-lock.json" ":(exclude)*.min.js" | head -c 100000 | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | # ----------------------------------------------------------------------------- | |
| 739 | + | # Internal: _aicommit_engine (Ask an AI CLI to commit or to plan a split) | |
| 740 | + | # ----------------------------------------------------------------------------- | |
| 741 | + | _aicommit_engine() { | |
| 742 | + | local engine="$1" | |
| 743 | + | local mode="$2" | |
| 744 | + | local input="$3" | |
| 745 | + | local name | |
| 746 | + | ||
| 727 | 747 | case "$engine" in | |
| 728 | - | claude|copilot|opencode) ;; | |
| 729 | - | *) | |
| 730 | - | printf "❌ Error: Invalid engine '%s'\n" "$engine" >&2 | |
| 731 | - | printf "Usage: aicommit [claude | copilot | opencode]\n" >&2 | |
| 732 | - | return 1 | |
| 748 | + | claude) name="Claude" ;; | |
| 749 | + | copilot) name="Copilot" ;; | |
| 750 | + | opencode) name="OpenCode" ;; | |
| 751 | + | esac | |
| 752 | + | ||
| 753 | + | # Status goes to stderr so plan output on stdout stays clean JSON. | |
| 754 | + | if [ "$mode" = "plan" ]; then | |
| 755 | + | printf "🧩 %s is planning per-module commits...\n" "$name" >&2 | |
| 756 | + | else | |
| 757 | + | printf "🤖 %s is analyzing staged changes...\n" "$name" >&2 | |
| 758 | + | fi | |
| 759 | + | ||
| 760 | + | case "$engine:$mode" in | |
| 761 | + | claude:commit) | |
| 762 | + | printf "%s\n" "$input" \ | |
| 763 | + | | command claude --allowedTools 'Bash(git commit *)' --permission-mode dontAsk -p \ | |
| 764 | + | "Read the provided instructions and diff, then generate and execute the commit." | |
| 765 | + | ;; | |
| 766 | + | claude:plan) | |
| 767 | + | printf "%s\n" "$input" \ | |
| 768 | + | | command claude --permission-mode dontAsk -p \ | |
| 769 | + | "Read the provided instructions and staged changes, then reply with only the JSON plan." | |
| 770 | + | ;; | |
| 771 | + | copilot:commit) | |
| 772 | + | copilot -p "$input" -s --no-ask-user --allow-tool='shell(git:*)' | |
| 773 | + | ;; | |
| 774 | + | copilot:plan) | |
| 775 | + | copilot -p "$input" -s --no-ask-user | |
| 776 | + | ;; | |
| 777 | + | opencode:commit) | |
| 778 | + | printf "%s\n" "$input" | opencode run --agent ai-commit | |
| 779 | + | ;; | |
| 780 | + | opencode:plan) | |
| 781 | + | printf "%s\n" "$input" | opencode run --agent ai-commit-plan | |
| 733 | 782 | ;; | |
| 734 | 783 | esac | |
| 735 | - | ||
| 736 | - | # 1. Get diff safely: exclude lock files/minified files and cap at ~100k characters to prevent CLI crashes | |
| 737 | - | diff=$(git diff --cached -- . ":(exclude)*.lock" ":(exclude)*-lock.json" ":(exclude)*.min.js" | head -c 100000) | |
| 738 | - | ||
| 739 | - | if [ -z "$diff" ]; then | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | # ----------------------------------------------------------------------------- | |
| 787 | + | # Internal: _aicommit_restage (Stage HEAD plus plan groups first..last from a tree) | |
| 788 | + | # ----------------------------------------------------------------------------- | |
| 789 | + | _aicommit_restage() { | |
| 790 | + | local tree="$1" | |
| 791 | + | local run_dir="$2" | |
| 792 | + | local first="$3" | |
| 793 | + | local last="$4" | |
| 794 | + | local number | |
| 795 | + | ||
| 796 | + | if git rev-parse -q --verify HEAD >/dev/null; then | |
| 797 | + | git read-tree HEAD || return 1 | |
| 798 | + | else | |
| 799 | + | git read-tree --empty || return 1 | |
| 800 | + | fi | |
| 801 | + | ||
| 802 | + | [ "$first" -le "$last" ] || return 0 | |
| 803 | + | ||
| 804 | + | number="$first" | |
| 805 | + | while [ "$number" -le "$last" ]; do | |
| 806 | + | cat "$run_dir/$(printf "%03d" "$number").files" | |
| 807 | + | number=$((number + 1)) | |
| 808 | + | done | git --literal-pathspecs restore --staged --source="$tree" --pathspec-from-file=- --pathspec-file-nul | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | # ----------------------------------------------------------------------------- | |
| 812 | + | # Internal: _aicommit_plan (Validate the AI split plan into per-group files) | |
| 813 | + | # ----------------------------------------------------------------------------- | |
| 814 | + | _aicommit_plan() { | |
| 815 | + | local run_dir="$1" | |
| 816 | + | ||
| 817 | + | python3 -c ' | |
| 818 | + | import json, os, re, sys | |
| 819 | + | ||
| 820 | + | sys.stdout.reconfigure(errors="backslashreplace") | |
| 821 | + | sys.stderr.reconfigure(errors="backslashreplace") | |
| 822 | + | run_dir = sys.argv[1] | |
| 823 | + | types = {"feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore"} | |
| 824 | + | ||
| 825 | + | with open(os.path.join(run_dir, "staged"), "rb") as fh: | |
| 826 | + | staged = [p for p in fh.read().decode("utf-8", "surrogateescape").split("\0") if p] | |
| 827 | + | with open(os.path.join(run_dir, "plan.out"), encoding="utf-8", errors="replace") as fh: | |
| 828 | + | raw = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", fh.read()) | |
| 829 | + | ||
| 830 | + | # Take the last {"commits": [...]} object in the reply, ignoring fences and prose. | |
| 831 | + | plan, decoder, pos = None, json.JSONDecoder(), raw.find("{") | |
| 832 | + | while pos != -1: | |
| 833 | + | try: | |
| 834 | + | obj, end = decoder.raw_decode(raw, pos) | |
| 835 | + | except ValueError: | |
| 836 | + | pos = raw.find("{", pos + 1) | |
| 837 | + | continue | |
| 838 | + | if isinstance(obj, dict) and isinstance(obj.get("commits"), list): | |
| 839 | + | plan = obj | |
| 840 | + | pos = raw.find("{", end) | |
| 841 | + | else: | |
| 842 | + | pos = raw.find("{", pos + 1) | |
| 843 | + | ||
| 844 | + | if plan is None: | |
| 845 | + | sys.exit("❌ Error: The AI reply did not contain a JSON commit plan.") | |
| 846 | + | ||
| 847 | + | # Every staged path must land in exactly one group; anything unassigned goes last. | |
| 848 | + | staged_set, seen, groups, warnings = set(staged), set(), [], [] | |
| 849 | + | for commit in plan["commits"]: | |
| 850 | + | if not isinstance(commit, dict) or not isinstance(commit.get("files"), list): | |
| 851 | + | continue | |
| 852 | + | files = [] | |
| 853 | + | for path in commit["files"]: | |
| 854 | + | if not isinstance(path, str): | |
| 855 | + | continue | |
| 856 | + | if path not in staged_set: | |
| 857 | + | warnings.append("ignoring path that is not staged: " + path) | |
| 858 | + | elif path in seen: | |
| 859 | + | warnings.append("ignoring duplicate path: " + path) | |
| 860 | + | else: | |
| 861 | + | seen.add(path) | |
| 862 | + | files.append(path) | |
| 863 | + | if not files: | |
| 864 | + | continue | |
| 865 | + | kind = str(commit.get("type", "")).strip().lower() | |
| 866 | + | scope = re.sub(r"[^a-z0-9._/-]", "", str(commit.get("scope", "")).lower()) | |
| 867 | + | label = "" | |
| 868 | + | if kind in types: | |
| 869 | + | label = kind + ("(" + scope + ")" if scope else "") | |
| 870 | + | groups.append((label, files)) | |
| 871 | + | ||
| 872 | + | missing = [p for p in staged if p not in seen] | |
| 873 | + | if missing: | |
| 874 | + | warnings.append("%d staged path(s) were not planned; adding them as a final commit" % len(missing)) | |
| 875 | + | groups.append(("", missing)) | |
| 876 | + | ||
| 877 | + | for number, (label, files) in enumerate(groups, 1): | |
| 878 | + | base = os.path.join(run_dir, "%03d" % number) | |
| 879 | + | with open(base + ".files", "wb") as fh: | |
| 880 | + | fh.write("".join(p + "\0" for p in files).encode("utf-8", "surrogateescape")) | |
| 881 | + | with open(base + ".label", "w") as fh: | |
| 882 | + | fh.write(label) | |
| 883 | + | with open(os.path.join(run_dir, "count"), "w") as fh: | |
| 884 | + | fh.write(str(len(groups))) | |
| 885 | + | ||
| 886 | + | for warning in warnings: | |
| 887 | + | print("⚠️ " + warning, file=sys.stderr) | |
| 888 | + | print("📋 Planned commits:") | |
| 889 | + | for number, (label, files) in enumerate(groups, 1): | |
| 890 | + | print(" %d. %s (%d file%s)" % (number, label or "unplanned", len(files), "" if len(files) == 1 else "s")) | |
| 891 | + | for path in files: | |
| 892 | + | print(" " + path) | |
| 893 | + | ' "$run_dir" | |
| 894 | + | } | |
| 895 | + | ||
| 896 | + | # ----------------------------------------------------------------------------- | |
| 897 | + | # Internal: _aicommit_modules_run (Plan, confirm, and commit each module group) | |
| 898 | + | # ----------------------------------------------------------------------------- | |
| 899 | + | _aicommit_modules_run() { | |
| 900 | + | local engine="$1" | |
| 901 | + | local assume_yes="$2" | |
| 902 | + | local run_dir="$3" | |
| 903 | + | local prompt_file="$HOME/.config/ai-commit-prompt.txt" | |
| 904 | + | local split_prompt_file="$HOME/.config/ai-commit-split-prompt.txt" | |
| 905 | + | local tree | |
| 906 | + | local total | |
| 907 | + | local current | |
| 908 | + | local group | |
| 909 | + | local label | |
| 910 | + | local hint | |
| 911 | + | local diff | |
| 912 | + | local expected | |
| 913 | + | local head_before | |
| 914 | + | local head_after | |
| 915 | + | local unexpected | |
| 916 | + | local reply | |
| 917 | + | ||
| 918 | + | if ! tree="$(git write-tree 2>/dev/null)"; then | |
| 919 | + | printf "❌ Error: Unable to snapshot the index (unresolved conflicts?).\n" >&2 | |
| 920 | + | return 1 | |
| 921 | + | fi | |
| 922 | + | ||
| 923 | + | git diff --cached --name-only --no-renames -z > "$run_dir/staged" || return 1 | |
| 924 | + | if [ ! -s "$run_dir/staged" ]; then | |
| 740 | 925 | printf "❌ Error: Nothing staged to commit.\n" >&2 | |
| 741 | 926 | return 1 | |
| 742 | 927 | fi | |
| 743 | 928 | ||
| 744 | - | if [ ! -f "$HOME/.config/ai-commit-prompt.txt" ]; then | |
| 745 | - | printf "❌ Error: Configuration file not found at ~/.config/ai-commit-prompt.txt\n" >&2 | |
| 746 | - | printf "Please create it and paste your system prompt inside.\n" >&2 | |
| 929 | + | _aicommit_engine "$engine" plan "$(cat "$split_prompt_file") | |
| 930 | + | ||
| 931 | + | Here are the staged files: | |
| 932 | + | $(git -c core.quotePath=false diff --cached --name-status -M) | |
| 933 | + | ||
| 934 | + | Here is the diffstat: | |
| 935 | + | $(git -c core.quotePath=false diff --cached --numstat -M) | |
| 936 | + | ||
| 937 | + | Here is the git diff: | |
| 938 | + | $(_aicommit_staged_diff)" > "$run_dir/plan.out" | |
| 939 | + | ||
| 940 | + | if ! _aicommit_plan "$run_dir"; then | |
| 941 | + | printf "Last lines of the AI reply:\n" >&2 | |
| 942 | + | tail -n 20 "$run_dir/plan.out" >&2 | |
| 747 | 943 | return 1 | |
| 748 | 944 | fi | |
| 945 | + | total="$(cat "$run_dir/count")" | |
| 749 | 946 | ||
| 750 | - | prompt=$(cat "$HOME/.config/ai-commit-prompt.txt") | |
| 947 | + | if [ "$assume_yes" -ne 1 ]; then | |
| 948 | + | if ! (: </dev/tty) 2>/dev/null; then | |
| 949 | + | printf "❌ Error: No interactive terminal to confirm the plan; rerun with --yes.\n" >&2 | |
| 950 | + | return 1 | |
| 951 | + | fi | |
| 952 | + | printf "Create these %s commit(s)? (Y/n) " "$total" >/dev/tty | |
| 953 | + | IFS= read -r reply </dev/tty || reply="n" | |
| 954 | + | case "$reply" in | |
| 955 | + | ""|y|Y|yes|YES|Yes) ;; | |
| 956 | + | *) | |
| 957 | + | printf "Aborted. Staged changes are untouched.\n" | |
| 958 | + | return 1 | |
| 959 | + | ;; | |
| 960 | + | esac | |
| 961 | + | fi | |
| 751 | 962 | ||
| 752 | - | case "$engine" in | |
| 753 | - | claude) | |
| 754 | - | echo "🤖 Claude is analyzing staged changes..." | |
| 755 | - | cat "$HOME/.config/ai-commit-prompt.txt" <(echo -e "\n\nHere is the git diff:\n$diff") \ | |
| 756 | - | | command claude --allowedTools 'Bash(git commit *)' --permission-mode dontAsk -p \ | |
| 757 | - | "Read the provided instructions and diff, then generate and execute the commit." | |
| 758 | - | ;; | |
| 963 | + | printf "💾 Staged snapshot: %s (recover with: git read-tree %s)\n" "$tree" "$tree" | |
| 964 | + | current=1 | |
| 965 | + | trap '_aicommit_restage "$tree" "$run_dir" "$current" "$total"; printf "\n⚠️ Interrupted. Uncommitted groups are staged again.\n" >&2; rm -rf "$run_dir"; exit 130' INT TERM | |
| 966 | + | ||
| 967 | + | while [ "$current" -le "$total" ]; do | |
| 968 | + | group="$run_dir/$(printf "%03d" "$current").files" | |
| 969 | + | label="$(cat "$run_dir/$(printf "%03d" "$current").label")" | |
| 970 | + | ||
| 971 | + | if ! _aicommit_restage "$tree" "$run_dir" "$current" "$current"; then | |
| 972 | + | printf "❌ Error: Unable to stage commit %s/%s.\n" "$current" "$total" >&2 | |
| 973 | + | _aicommit_restage "$tree" "$run_dir" "$current" "$total" | |
| 974 | + | return 1 | |
| 975 | + | fi | |
| 976 | + | ||
| 977 | + | expected="$(git write-tree)" | |
| 978 | + | head_before="$(git rev-parse -q --verify HEAD)" | |
| 979 | + | diff="$(_aicommit_staged_diff)" | |
| 980 | + | [ -n "$diff" ] || diff="$(git diff --cached --stat)" | |
| 981 | + | ||
| 982 | + | hint="This is commit $current of $total, split by module." | |
| 983 | + | if [ -n "$label" ]; then | |
| 984 | + | hint="$hint The planned type and scope is $label; use it unless the diff clearly calls for another." | |
| 985 | + | fi | |
| 759 | 986 | ||
| 760 | - | copilot) | |
| 761 | - | echo "🤖 Copilot is analyzing staged changes..." | |
| 762 | - | copilot -p "$prompt | |
| 987 | + | printf "\n[%s/%s] %s\n" "$current" "$total" "${label:-unplanned}" | |
| 988 | + | _aicommit_engine "$engine" commit "$(cat "$prompt_file") | |
| 989 | + | ||
| 990 | + | $hint | |
| 763 | 991 | ||
| 764 | 992 | Here is the git diff: | |
| 765 | - | $diff" -s --no-ask-user --allow-tool='shell(git:*)' | |
| 766 | - | ;; | |
| 993 | + | $diff" | |
| 767 | 994 | ||
| 768 | - | opencode) | |
| 769 | - | echo "🤖 OpenCode is analyzing staged changes..." | |
| 770 | - | cat "$HOME/.config/ai-commit-prompt.txt" <(echo -e "\n\nHere is the git diff:\n$diff") | opencode run --agent ai-commit | |
| 771 | - | ;; | |
| 995 | + | head_after="$(git rev-parse -q --verify HEAD)" | |
| 996 | + | if [ -z "$head_after" ] || [ "$head_after" = "$head_before" ]; then | |
| 997 | + | printf "❌ Error: No commit was created for group %s/%s.\n" "$current" "$total" >&2 | |
| 998 | + | _aicommit_restage "$tree" "$run_dir" "$current" "$total" | |
| 999 | + | printf "Uncommitted groups are staged again.\n" >&2 | |
| 1000 | + | return 1 | |
| 1001 | + | fi | |
| 772 | 1002 | ||
| 773 | - | *) | |
| 774 | - | printf "❌ Error: Invalid engine '%s'\n" "$engine" >&2 | |
| 775 | - | printf "Usage: aicommit [claude | copilot | opencode]\n" >&2 | |
| 1003 | + | # Hooks may reformat this group's files, but nothing outside it may sneak in. | |
| 1004 | + | unexpected="$(LC_ALL=C comm -13 \ | |
| 1005 | + | <(tr '\0' '\n' < "$group" | LC_ALL=C sort) \ | |
| 1006 | + | <(git diff --name-only --no-renames -z "$expected" HEAD | tr '\0' '\n' | LC_ALL=C sort))" | |
| 1007 | + | if [ -n "$unexpected" ]; then | |
| 1008 | + | printf "❌ Error: Commit %s/%s included files outside its group:\n%s\n" "$current" "$total" "$unexpected" >&2 | |
| 1009 | + | _aicommit_restage "$tree" "$run_dir" "$((current + 1))" "$total" | |
| 1010 | + | printf "Inspect it with 'git show'; undo it with 'git reset --soft HEAD~1'.\n" >&2 | |
| 776 | 1011 | return 1 | |
| 777 | - | ;; | |
| 778 | - | esac | |
| 1012 | + | fi | |
| 1013 | + | ||
| 1014 | + | current=$((current + 1)) | |
| 1015 | + | done | |
| 1016 | + | ||
| 1017 | + | trap - INT TERM | |
| 1018 | + | printf "\n✅ Created %s commit(s):\n" "$total" | |
| 1019 | + | git --no-pager log --oneline -n "$total" | |
| 1020 | + | } | |
| 1021 | + | ||
| 1022 | + | # ----------------------------------------------------------------------------- | |
| 1023 | + | # Internal: _aicommit_modules (Split staged changes into per-module commits) | |
| 1024 | + | # ----------------------------------------------------------------------------- | |
| 1025 | + | _aicommit_modules() { | |
| 1026 | + | local engine="$1" | |
| 1027 | + | local assume_yes="$2" | |
| 1028 | + | local top | |
| 1029 | + | local run_dir | |
| 1030 | + | local rc | |
| 1031 | + | ||
| 1032 | + | if [ ! -f "$HOME/.config/ai-commit-split-prompt.txt" ]; then | |
| 1033 | + | printf "❌ Error: Configuration file not found at ~/.config/ai-commit-split-prompt.txt\n" >&2 | |
| 1034 | + | return 1 | |
| 1035 | + | fi | |
| 1036 | + | ||
| 1037 | + | require_cli python3 "Python 3" || return 1 | |
| 1038 | + | ||
| 1039 | + | if ! top="$(git rev-parse --show-toplevel 2>/dev/null)"; then | |
| 1040 | + | printf "❌ Error: Not inside a git work tree.\n" >&2 | |
| 1041 | + | return 1 | |
| 1042 | + | fi | |
| 1043 | + | ||
| 1044 | + | run_dir="$(mktemp -d "${TMPDIR:-/tmp}/aicommit.XXXXXX")" || return 1 | |
| 1045 | + | ||
| 1046 | + | # Subshell: run from the repo root and keep the traps away from the caller. | |
| 1047 | + | ( | |
| 1048 | + | trap 'rm -rf "$run_dir"; exit 130' INT TERM | |
| 1049 | + | cd "$top" || exit 1 | |
| 1050 | + | _aicommit_modules_run "$engine" "$assume_yes" "$run_dir" | |
| 1051 | + | ) | |
| 1052 | + | rc=$? | |
| 1053 | + | rm -rf "$run_dir" | |
| 1054 | + | return "$rc" | |
| 1055 | + | } | |
| 1056 | + | ||
| 1057 | + | # ----------------------------------------------------------------------------- | |
| 1058 | + | # Function: aicommit (Automated Conventional Commit Engine Wrapper) | |
| 1059 | + | # ----------------------------------------------------------------------------- | |
| 1060 | + | aicommit() { | |
| 1061 | + | local engine="claude" | |
| 1062 | + | local modules=0 | |
| 1063 | + | local assume_yes=0 | |
| 1064 | + | local arg | |
| 1065 | + | local diff | |
| 1066 | + | local prompt_file="$HOME/.config/ai-commit-prompt.txt" | |
| 1067 | + | ||
| 1068 | + | for arg in "$@"; do | |
| 1069 | + | case "$arg" in | |
| 1070 | + | claude|copilot|opencode) engine="$arg" ;; | |
| 1071 | + | --modules) modules=1 ;; | |
| 1072 | + | -y|--yes) assume_yes=1 ;; | |
| 1073 | + | -h|--help) | |
| 1074 | + | _aicommit_usage | |
| 1075 | + | return 0 | |
| 1076 | + | ;; | |
| 1077 | + | *) | |
| 1078 | + | printf "❌ Error: Invalid argument '%s'\n" "$arg" >&2 | |
| 1079 | + | _aicommit_usage >&2 | |
| 1080 | + | return 1 | |
| 1081 | + | ;; | |
| 1082 | + | esac | |
| 1083 | + | done | |
| 1084 | + | ||
| 1085 | + | if [ ! -f "$prompt_file" ]; then | |
| 1086 | + | printf "❌ Error: Configuration file not found at ~/.config/ai-commit-prompt.txt\n" >&2 | |
| 1087 | + | printf "Please create it and paste your system prompt inside.\n" >&2 | |
| 1088 | + | return 1 | |
| 1089 | + | fi | |
| 1090 | + | ||
| 1091 | + | if [ "$modules" -eq 1 ]; then | |
| 1092 | + | _aicommit_modules "$engine" "$assume_yes" | |
| 1093 | + | return | |
| 1094 | + | fi | |
| 1095 | + | ||
| 1096 | + | diff="$(_aicommit_staged_diff)" | |
| 1097 | + | if [ -z "$diff" ]; then | |
| 1098 | + | printf "❌ Error: Nothing staged to commit.\n" >&2 | |
| 1099 | + | return 1 | |
| 1100 | + | fi | |
| 1101 | + | ||
| 1102 | + | _aicommit_engine "$engine" commit "$(cat "$prompt_file") | |
| 1103 | + | ||
| 1104 | + | Here is the git diff: | |
| 1105 | + | $diff" | |
| 779 | 1106 | } | |
| 780 | 1107 | ||
| 781 | 1108 | # ----------------------------------------------------------------------------- | |
zsh_macos.sh
| @@ -29,7 +29,9 @@ CONFIG_FILES=( | |||
| 29 | 29 | ".zshrc" | |
| 30 | 30 | ".config/starship.toml" | |
| 31 | 31 | ".config/ai-commit-prompt.txt" | |
| 32 | + | ".config/ai-commit-split-prompt.txt" | |
| 32 | 33 | ".config/opencode/agents/ai-commit.md" | |
| 34 | + | ".config/opencode/agents/ai-commit-plan.md" | |
| 33 | 35 | ) | |
| 34 | 36 | ||
| 35 | 37 | # ============================= | |
| @@ -92,7 +94,9 @@ download_configs() { | |||
| 92 | 94 | local remote_file="${f#.}" | |
| 93 | 95 | [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml" | |
| 94 | 96 | [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt" | |
| 97 | + | [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt" | |
| 95 | 98 | [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md" | |
| 99 | + | [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md" | |
| 96 | 100 | local url="$GIST_RAW_BASE/$remote_file" | |
| 97 | 101 | local target="$HOME/$f" | |
| 98 | 102 | local tmp="${target}.tmp.$$" | |
zsh_ubuntu.sh
| @@ -32,7 +32,9 @@ CONFIG_FILES=( | |||
| 32 | 32 | ".zshrc" | |
| 33 | 33 | ".config/starship.toml" | |
| 34 | 34 | ".config/ai-commit-prompt.txt" | |
| 35 | + | ".config/ai-commit-split-prompt.txt" | |
| 35 | 36 | ".config/opencode/agents/ai-commit.md" | |
| 37 | + | ".config/opencode/agents/ai-commit-plan.md" | |
| 36 | 38 | ) | |
| 37 | 39 | ||
| 38 | 40 | # ============================= | |
| @@ -183,7 +185,9 @@ download_configs() { | |||
| 183 | 185 | local remote_file="${f#.}" | |
| 184 | 186 | [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml" | |
| 185 | 187 | [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt" | |
| 188 | + | [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt" | |
| 186 | 189 | [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md" | |
| 190 | + | [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md" | |
| 187 | 191 | local url="$GIST_RAW_BASE/$remote_file" | |
| 188 | 192 | local target="$HOME/$f" | |
| 189 | 193 | local tmp="${target}.tmp.$$" | |
zsh_wsl.sh
| @@ -32,7 +32,9 @@ CONFIG_FILES=( | |||
| 32 | 32 | ".zshrc" | |
| 33 | 33 | ".config/starship.toml" | |
| 34 | 34 | ".config/ai-commit-prompt.txt" | |
| 35 | + | ".config/ai-commit-split-prompt.txt" | |
| 35 | 36 | ".config/opencode/agents/ai-commit.md" | |
| 37 | + | ".config/opencode/agents/ai-commit-plan.md" | |
| 36 | 38 | ) | |
| 37 | 39 | ||
| 38 | 40 | # ============================= | |
| @@ -185,7 +187,9 @@ download_configs() { | |||
| 185 | 187 | local remote_file="${f#.}" | |
| 186 | 188 | [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml" | |
| 187 | 189 | [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt" | |
| 190 | + | [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt" | |
| 188 | 191 | [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md" | |
| 192 | + | [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md" | |
| 189 | 193 | local url="$GIST_RAW_BASE/$remote_file" | |
| 190 | 194 | local target="$HOME/$f" | |
| 191 | 195 | local tmp="${target}.tmp.$$" | |
Vernon Wee Hong KOH ha revisionato questo gist 2 weeks ago. Vai alla revisione
1 file changed, 10 insertions, 10 deletions
func
| @@ -102,18 +102,18 @@ open_file() { | |||
| 102 | 102 | alias open='open_file' | |
| 103 | 103 | ||
| 104 | 104 | # ----------------------------------------------------------------------------- | |
| 105 | - | # Function: clear_history (Supports shell, Claude, Codex, and OpenCode) | |
| 105 | + | # Function: ch (Supports shell, Claude, Codex, and OpenCode) | |
| 106 | 106 | # ----------------------------------------------------------------------------- | |
| 107 | - | clear_history() { | |
| 107 | + | ch() { | |
| 108 | 108 | if [ "$#" -gt 1 ]; then | |
| 109 | - | printf "Usage: clear_history [--all | shell | claude | codex | opencode]\n" >&2 | |
| 109 | + | printf "Usage: ch [--all | shell | claude | codex | opencode]\n" >&2 | |
| 110 | 110 | return 2 | |
| 111 | 111 | fi | |
| 112 | 112 | case "${1:-}" in | |
| 113 | 113 | --all) | |
| 114 | 114 | local result=0 target | |
| 115 | 115 | for target in claude codex opencode shell; do | |
| 116 | - | clear_history "$target" || result=1 | |
| 116 | + | ch "$target" || result=1 | |
| 117 | 117 | done | |
| 118 | 118 | ||
| 119 | 119 | if [ -t 1 ]; then | |
| @@ -172,7 +172,7 @@ clear_history() { | |||
| 172 | 172 | echo "Shell history cleared." | |
| 173 | 173 | ;; | |
| 174 | 174 | *) | |
| 175 | - | printf "Usage: clear_history [--all | shell | claude | codex | opencode]\n" >&2 | |
| 175 | + | printf "Usage: ch [--all | shell | claude | codex | opencode]\n" >&2 | |
| 176 | 176 | return 2 | |
| 177 | 177 | ;; | |
| 178 | 178 | esac | |
| @@ -207,7 +207,7 @@ require_cli() { | |||
| 207 | 207 | claude() { | |
| 208 | 208 | # Shortcut for clearing cache | |
| 209 | 209 | if [[ "$1" == "clear" ]]; then | |
| 210 | - | clear_history claude | |
| 210 | + | ch claude | |
| 211 | 211 | return $? | |
| 212 | 212 | fi | |
| 213 | 213 | ||
| @@ -813,9 +813,9 @@ create() { | |||
| 813 | 813 | } | |
| 814 | 814 | ||
| 815 | 815 | # ----------------------------------------------------------------------------- | |
| 816 | - | # Function: kill_port (Kill or check the process using a port) | |
| 816 | + | # Function: ckp (Kill or check the process using a port) | |
| 817 | 817 | # ----------------------------------------------------------------------------- | |
| 818 | - | kill_port() { | |
| 818 | + | ckp() { | |
| 819 | 819 | local action="kill" | |
| 820 | 820 | local signal="TERM" | |
| 821 | 821 | local port | |
| @@ -837,7 +837,7 @@ kill_port() { | |||
| 837 | 837 | shift | |
| 838 | 838 | ;; | |
| 839 | 839 | -h|--help|"") | |
| 840 | - | printf "Usage: kill_port [--check|-c|--kill|-k|--force|-f] <port_number>\n" >&2 | |
| 840 | + | printf "Usage: ckp [--check|-c|--kill|-k|--force|-f] <port_number>\n" >&2 | |
| 841 | 841 | return 1 | |
| 842 | 842 | ;; | |
| 843 | 843 | esac | |
| @@ -845,7 +845,7 @@ kill_port() { | |||
| 845 | 845 | port="${1:-}" | |
| 846 | 846 | if [[ ! "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then | |
| 847 | 847 | printf "Error: port must be a number between 1 and 65535.\n" >&2 | |
| 848 | - | printf "Usage: kill_port [--check|-c|--kill|-k|--force|-f] <port_number>\n" >&2 | |
| 848 | + | printf "Usage: ckp [--check|-c|--kill|-k|--force|-f] <port_number>\n" >&2 | |
| 849 | 849 | return 1 | |
| 850 | 850 | fi | |
| 851 | 851 | ||
Vernon Wee Hong KOH ha revisionato questo gist 3 weeks ago. Vai alla revisione
1 file changed, 9 insertions
alias
| @@ -31,6 +31,15 @@ alias gstu='git stash push --keep-index --include-untracked' | |||
| 31 | 31 | alias gsta='git stash push --include-untracked' | |
| 32 | 32 | alias grmrf='git checkout -- . && git clean -fd' | |
| 33 | 33 | ||
| 34 | + | # Create a feature worktree from origin/integration: gwa pill | |
| 35 | + | gwa() { | |
| 36 | + | if [ "$#" -ne 1 ] || [ -z "$1" ]; then | |
| 37 | + | printf 'Usage: gwa <feature-name>\n' >&2 | |
| 38 | + | return 1 | |
| 39 | + | fi | |
| 40 | + | git worktree add "../worktree/$1" -b "feature/$1" origin/integration | |
| 41 | + | } | |
| 42 | + | ||
| 34 | 43 | # List loaded aliases, optionally filtered by a case-insensitive keyword. | |
| 35 | 44 | ah() { | |
| 36 | 45 | if [ "$#" -eq 0 ]; then | |