diff --git a/.github/workflows/install-scripts-local.yml b/.github/workflows/install-scripts-local.yml new file mode 100644 index 00000000..660a4f47 --- /dev/null +++ b/.github/workflows/install-scripts-local.yml @@ -0,0 +1,129 @@ +name: Install Scripts (Local Build) + +on: + pull_request: + branches: [main] + push: + branches: [main] + merge_group: + branches: [main] + +jobs: + install-local-unix: + name: Local install.sh on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Build git-ai + run: cargo build --release --bin git-ai + + - name: Prepare test home and fake Claude Code + run: | + TEST_HOME="$RUNNER_TEMP/git-ai-home" + mkdir -p "$TEST_HOME/.config/fish" "$TEST_HOME/.claude" + touch "$TEST_HOME/.bashrc" "$TEST_HOME/.zshrc" "$TEST_HOME/.config/fish/config.fish" + echo "HOME=$TEST_HOME" >> "$GITHUB_ENV" + + BIN_DIR="$RUNNER_TEMP/fake-bin" + mkdir -p "$BIN_DIR" + cat > "$BIN_DIR/claude" <<'EOF' + #!/bin/sh + echo "2.0.0 (Claude Code)" + EOF + chmod +x "$BIN_DIR/claude" + echo "$BIN_DIR" >> "$GITHUB_PATH" + + - name: Run install.sh with local binary + env: + GIT_AI_LOCAL_BINARY: ${{ github.workspace }}/target/release/git-ai + run: | + chmod +x ./install.sh + ./install.sh + + - name: Verify shell configs and Claude hooks + run: | + INSTALL_DIR="$HOME/.git-ai/bin" + test -x "$INSTALL_DIR/git-ai" + grep -F "$INSTALL_DIR" "$HOME/.bashrc" + grep -F "$INSTALL_DIR" "$HOME/.zshrc" + grep -F "fish_add_path -g \"$INSTALL_DIR\"" "$HOME/.config/fish/config.fish" + grep -F "checkpoint claude" "$HOME/.claude/settings.json" + + install-local-windows: + name: Local install.ps1 on windows-latest + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Build git-ai + run: cargo build --release --bin git-ai + + - name: Prepare test home and fake Claude Code + shell: pwsh + run: | + $testHome = Join-Path $env:RUNNER_TEMP "git-ai-home" + New-Item -ItemType Directory -Force -Path $testHome | Out-Null + $homeDrive = [System.IO.Path]::GetPathRoot($testHome).TrimEnd('\') + $homePath = $testHome.Substring($homeDrive.Length) + Add-Content -Path $env:GITHUB_ENV -Value "TEST_HOME=$testHome" + Add-Content -Path $env:GITHUB_ENV -Value "TEST_HOME_DRIVE=$homeDrive" + Add-Content -Path $env:GITHUB_ENV -Value "TEST_HOME_PATH=$homePath" + + $claudeDir = Join-Path $testHome ".claude" + New-Item -ItemType Directory -Force -Path $claudeDir | Out-Null + + $binDir = Join-Path $env:RUNNER_TEMP "fake-bin" + New-Item -ItemType Directory -Force -Path $binDir | Out-Null + $claudeCmd = Join-Path $binDir "claude.cmd" + "@echo 2.0.0 (Claude Code)" | Out-File -FilePath $claudeCmd -Encoding ASCII -Force + Add-Content -Path $env:GITHUB_PATH -Value $binDir + + - name: Run install.ps1 with local binary + shell: pwsh + env: + GIT_AI_LOCAL_BINARY: ${{ github.workspace }}\target\release\git-ai.exe + HOME: ${{ env.TEST_HOME }} + USERPROFILE: ${{ env.TEST_HOME }} + HOMEDRIVE: ${{ env.TEST_HOME_DRIVE }} + HOMEPATH: ${{ env.TEST_HOME_PATH }} + run: | + Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force + ./install.ps1 + + - name: Verify Claude hooks + shell: pwsh + env: + HOME: ${{ env.TEST_HOME }} + USERPROFILE: ${{ env.TEST_HOME }} + HOMEDRIVE: ${{ env.TEST_HOME_DRIVE }} + HOMEPATH: ${{ env.TEST_HOME_PATH }} + run: | + $installDir = Join-Path $env:USERPROFILE ".git-ai\bin" + if (-not (Test-Path -LiteralPath (Join-Path $installDir "git-ai.exe"))) { throw "git-ai.exe not installed" } + $settings = Join-Path $env:USERPROFILE ".claude\settings.json" + if (-not (Select-String -Path $settings -Pattern "checkpoint claude")) { throw "Claude hooks not configured" } diff --git a/.github/workflows/install-scripts-nightly.yml b/.github/workflows/install-scripts-nightly.yml new file mode 100644 index 00000000..32282c37 --- /dev/null +++ b/.github/workflows/install-scripts-nightly.yml @@ -0,0 +1,93 @@ +name: Install Scripts (Nightly) + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +jobs: + install-nightly-unix: + name: Nightly install.sh on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - name: Prepare test home and fake Claude Code + run: | + TEST_HOME="$RUNNER_TEMP/git-ai-home" + mkdir -p "$TEST_HOME/.config/fish" "$TEST_HOME/.claude" + touch "$TEST_HOME/.bashrc" "$TEST_HOME/.zshrc" "$TEST_HOME/.config/fish/config.fish" + echo "HOME=$TEST_HOME" >> "$GITHUB_ENV" + + BIN_DIR="$RUNNER_TEMP/fake-bin" + mkdir -p "$BIN_DIR" + cat > "$BIN_DIR/claude" <<'EOF' + #!/bin/sh + echo "2.0.0 (Claude Code)" + EOF + chmod +x "$BIN_DIR/claude" + echo "$BIN_DIR" >> "$GITHUB_PATH" + + - name: Run install.sh from usegitai.com + run: curl -fsSL https://usegitai.com/install.sh | bash + + - name: Verify shell configs and Claude hooks + run: | + INSTALL_DIR="$HOME/.git-ai/bin" + test -x "$INSTALL_DIR/git-ai" + grep -F "$INSTALL_DIR" "$HOME/.bashrc" + grep -F "$INSTALL_DIR" "$HOME/.zshrc" + grep -F "fish_add_path -g \"$INSTALL_DIR\"" "$HOME/.config/fish/config.fish" + grep -F "checkpoint claude" "$HOME/.claude/settings.json" + + install-nightly-windows: + name: Nightly install.ps1 on windows-latest + runs-on: windows-latest + + steps: + - name: Prepare test home and fake Claude Code + shell: pwsh + run: | + $testHome = Join-Path $env:RUNNER_TEMP "git-ai-home" + New-Item -ItemType Directory -Force -Path $testHome | Out-Null + $homeDrive = [System.IO.Path]::GetPathRoot($testHome).TrimEnd('\') + $homePath = $testHome.Substring($homeDrive.Length) + Add-Content -Path $env:GITHUB_ENV -Value "TEST_HOME=$testHome" + Add-Content -Path $env:GITHUB_ENV -Value "TEST_HOME_DRIVE=$homeDrive" + Add-Content -Path $env:GITHUB_ENV -Value "TEST_HOME_PATH=$homePath" + + $claudeDir = Join-Path $testHome ".claude" + New-Item -ItemType Directory -Force -Path $claudeDir | Out-Null + + $binDir = Join-Path $env:RUNNER_TEMP "fake-bin" + New-Item -ItemType Directory -Force -Path $binDir | Out-Null + $claudeCmd = Join-Path $binDir "claude.cmd" + "@echo 2.0.0 (Claude Code)" | Out-File -FilePath $claudeCmd -Encoding ASCII -Force + Add-Content -Path $env:GITHUB_PATH -Value $binDir + + - name: Run install.ps1 from usegitai.com + shell: pwsh + env: + HOME: ${{ env.TEST_HOME }} + USERPROFILE: ${{ env.TEST_HOME }} + HOMEDRIVE: ${{ env.TEST_HOME_DRIVE }} + HOMEPATH: ${{ env.TEST_HOME_PATH }} + run: | + Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force + Invoke-WebRequest -UseBasicParsing https://usegitai.com/install.ps1 | Invoke-Expression + + - name: Verify Claude hooks + shell: pwsh + env: + HOME: ${{ env.TEST_HOME }} + USERPROFILE: ${{ env.TEST_HOME }} + HOMEDRIVE: ${{ env.TEST_HOME_DRIVE }} + HOMEPATH: ${{ env.TEST_HOME_PATH }} + run: | + $installDir = Join-Path $env:USERPROFILE ".git-ai\bin" + if (-not (Test-Path -LiteralPath (Join-Path $installDir "git-ai.exe"))) { throw "git-ai.exe not installed" } + $settings = Join-Path $env:USERPROFILE ".claude\settings.json" + if (-not (Select-String -Path $settings -Pattern "checkpoint claude")) { throw "Claude hooks not configured" } diff --git a/install.ps1 b/install.ps1 index 9923eb44..8d5b5396 100644 --- a/install.ps1 +++ b/install.ps1 @@ -272,8 +272,10 @@ $os = 'windows' $binaryName = "git-ai-$os-$arch" # Determine release tag -# Priority: 1. Pinned version (for release builds), 2. Environment variable, 3. "latest" -if ($PinnedVersion -ne '__VERSION_PLACEHOLDER__') { +# Priority: 1. Local binary override, 2. Pinned version (for release builds), 3. Environment variable, 4. "latest" +if (-not [string]::IsNullOrWhiteSpace($env:GIT_AI_LOCAL_BINARY)) { + $releaseTag = 'local' +} elseif ($PinnedVersion -ne '__VERSION_PLACEHOLDER__') { # Version-pinned install script from a release $releaseTag = $PinnedVersion $downloadUrlExe = "https://usegitai.com/worker/releases/download/$releaseTag/$binaryName.exe" @@ -311,7 +313,14 @@ function Try-Download { # Track which download URL succeeded for checksum verification $downloadedBinaryName = $null -if (Try-Download -Url $downloadUrlExe) { +if (-not [string]::IsNullOrWhiteSpace($env:GIT_AI_LOCAL_BINARY)) { + if (-not (Test-Path -LiteralPath $env:GIT_AI_LOCAL_BINARY)) { + Remove-Item -Force -ErrorAction SilentlyContinue $tmpFile + Write-ErrorAndExit "Local binary not found at $($env:GIT_AI_LOCAL_BINARY)" + } + Copy-Item -Force -Path $env:GIT_AI_LOCAL_BINARY -Destination $tmpFile + $downloadedBinaryName = "$binaryName.exe" +} elseif (Try-Download -Url $downloadUrlExe) { $downloadedBinaryName = "$binaryName.exe" } elseif (Try-Download -Url $downloadUrlNoExt) { $downloadedBinaryName = $binaryName diff --git a/install.sh b/install.sh index 0107dcb6..3bd9d8b8 100755 --- a/install.sh +++ b/install.sh @@ -216,8 +216,11 @@ esac BINARY_NAME="git-ai-${OS}-${ARCH}" # Determine release tag -# Priority: 1. Pinned version (for release builds), 2. Environment variable, 3. "latest" -if [ "$PINNED_VERSION" != "__VERSION_PLACEHOLDER__" ]; then +# Priority: 1. Local binary override, 2. Pinned version (for release builds), 3. Environment variable, 4. "latest" +if [ -n "${GIT_AI_LOCAL_BINARY:-}" ]; then + RELEASE_TAG="local" + DOWNLOAD_URL="" +elif [ "$PINNED_VERSION" != "__VERSION_PLACEHOLDER__" ]; then # Version-pinned install script from a release RELEASE_TAG="$PINNED_VERSION" DOWNLOAD_URL="https://usegitai.com/worker/releases/download/${RELEASE_TAG}/${BINARY_NAME}" @@ -238,11 +241,19 @@ INSTALL_DIR="$HOME/.git-ai/bin" mkdir -p "$INSTALL_DIR" # Download and install -echo "Downloading git-ai (release: ${RELEASE_TAG})..." TMP_FILE="${INSTALL_DIR}/git-ai.tmp.$$" -if ! curl --fail --location --silent --show-error -o "$TMP_FILE" "$DOWNLOAD_URL"; then - rm -f "$TMP_FILE" 2>/dev/null || true - error "Failed to download binary (HTTP error)" +if [ -n "${GIT_AI_LOCAL_BINARY:-}" ]; then + echo "Using local git-ai binary (release: ${RELEASE_TAG})..." + if [ ! -f "$GIT_AI_LOCAL_BINARY" ]; then + error "Local binary not found at $GIT_AI_LOCAL_BINARY" + fi + cp "$GIT_AI_LOCAL_BINARY" "$TMP_FILE" +else + echo "Downloading git-ai (release: ${RELEASE_TAG})..." + if ! curl --fail --location --silent --show-error -o "$TMP_FILE" "$DOWNLOAD_URL"; then + rm -f "$TMP_FILE" 2>/dev/null || true + error "Failed to download binary (HTTP error)" + fi fi # Basic validation: ensure file is not empty diff --git a/src/config.rs b/src/config.rs index 61a03186..86c9b452 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::feature_flags::FeatureFlags; use crate::git::repository::Repository; +use crate::mdm::utils::home_dir; #[cfg(any(test, feature = "test-support"))] use std::sync::RwLock; @@ -681,8 +682,7 @@ fn load_file_config() -> Option { } fn config_file_path() -> Option { - let home = dirs::home_dir()?; - Some(home.join(".git-ai").join("config.json")) + Some(home_dir().join(".git-ai").join("config.json")) } /// Public accessor for config file path @@ -693,16 +693,7 @@ pub fn config_file_path_public() -> Option { /// Returns the path to the git-ai base directory (~/.git-ai) pub fn git_ai_dir_path() -> Option { - #[cfg(windows)] - { - let home = env::var("USERPROFILE").ok()?; - Some(Path::new(&home).join(".git-ai")) - } - #[cfg(not(windows))] - { - let home = env::var("HOME").ok()?; - Some(Path::new(&home).join(".git-ai")) - } + Some(home_dir().join(".git-ai")) } /// Returns the path to the internal state directory (~/.git-ai/internal) diff --git a/src/mdm/utils.rs b/src/mdm/utils.rs index 374802d3..647db9f4 100644 --- a/src/mdm/utils.rs +++ b/src/mdm/utils.rs @@ -107,7 +107,41 @@ pub fn is_github_codespaces() -> bool { /// Get the user's home directory pub fn home_dir() -> PathBuf { - dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) + #[cfg(windows)] + { + if let Ok(userprofile) = std::env::var("USERPROFILE") { + if !userprofile.is_empty() { + return PathBuf::from(userprofile); + } + } + + if let (Ok(home_drive), Ok(home_path)) = + (std::env::var("HOMEDRIVE"), std::env::var("HOMEPATH")) + { + if !home_drive.is_empty() && !home_path.is_empty() { + return PathBuf::from(format!("{}{}", home_drive, home_path)); + } + } + + if let Ok(home) = std::env::var("HOME") + && !home.is_empty() + { + return PathBuf::from(home); + } + + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + } + + #[cfg(not(windows))] + { + if let Ok(home) = std::env::var("HOME") + && !home.is_empty() + { + return PathBuf::from(home); + } + + dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) + } } /// Write data to a file atomically (write to temp, then rename)