diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100755 index 0000000..fd875bc --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,72 @@ +#!/bin/sh +# GITBUTLER_MANAGED_HOOK_V1 +# This hook auto-cleans GitButler hooks when you checkout away from gitbutler/workspace. + +PREV_HEAD=$1 +NEW_HEAD=$2 +BRANCH_CHECKOUT=$3 + +# Only act on branch checkouts (not file checkouts) +if [ "$BRANCH_CHECKOUT" != "1" ]; then + # Run user's hook if it exists + if [ -x "$(dirname "$0")/post-checkout-user" ]; then + exec "$(dirname "$0")/post-checkout-user" "$@" + fi + exit 0 +fi + +# Get the new branch name +NEW_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null) + +# If we just left gitbutler/workspace (and aren't coming back to it) +PREV_BRANCH=$(git name-rev --name-only "$PREV_HEAD" 2>/dev/null | sed 's|^remotes/||') +if echo "$PREV_BRANCH" | grep -q "gitbutler/workspace"; then + if [ "$NEW_BRANCH" != "gitbutler/workspace" ]; then + echo "" + echo "NOTE: You have left GitButler's managed workspace branch." + echo "Cleaning up GitButler hooks..." + + HOOKS_DIR=$(dirname "$0") + + # Restore pre-commit - but only if it's GitButler-managed + if [ -f "$HOOKS_DIR/pre-commit-user" ]; then + mv "$HOOKS_DIR/pre-commit-user" "$HOOKS_DIR/pre-commit" + echo " Restored: pre-commit" + elif [ -f "$HOOKS_DIR/pre-commit" ]; then + # Only remove if it's GitButler-managed (has our signature) + if grep -q "GITBUTLER_MANAGED_HOOK_V1" "$HOOKS_DIR/pre-commit"; then + rm "$HOOKS_DIR/pre-commit" + echo " Removed: pre-commit (GitButler managed)" + else + echo " Warning: pre-commit hook is not GitButler-managed, leaving it untouched" + fi + fi + + # Run user's post-checkout if it exists, then clean up + if [ -x "$HOOKS_DIR/post-checkout-user" ]; then + "$HOOKS_DIR/post-checkout-user" "$@" + mv "$HOOKS_DIR/post-checkout-user" "$HOOKS_DIR/post-checkout" + echo " Restored: post-checkout" + else + # Only remove self if we're GitButler-managed (we should be, but check anyway) + if grep -q "GITBUTLER_MANAGED_HOOK_V1" "$HOOKS_DIR/post-checkout"; then + rm "$HOOKS_DIR/post-checkout" + echo " Removed: post-checkout (GitButler managed)" + else + echo " Warning: post-checkout hook is not GitButler-managed, leaving it untouched" + fi + fi + + echo "" + echo "To return to GitButler mode, run: but setup" + echo "" + exit 0 + fi +fi + +# Run user's hook if it exists +if [ -x "$(dirname "$0")/post-checkout-user" ]; then + exec "$(dirname "$0")/post-checkout-user" "$@" +fi + +exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 339bbb8..869a329 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,44 +1,43 @@ #!/bin/sh -# Pre-commit hook: run Deno checks on staged files +# GITBUTLER_MANAGED_HOOK_V1 +# This hook is managed by GitButler to prevent accidental commits on the workspace branch. +# Your original pre-commit hook has been preserved as 'pre-commit-user'. -echo "🔍 Running pre-commit checks..." +HOOKS_DIR=$(dirname "$0") -# Get staged .ts files (excluding node_modules, .opencode) -STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.ts$' || true) - -if [ -z "$STAGED_FILES" ]; then - echo "✨ No TypeScript files staged, skipping checks." - exit 0 +# Run user's hook first if it exists - if it fails, stop here +if [ -x "$HOOKS_DIR/pre-commit-user" ]; then + "$HOOKS_DIR/pre-commit-user" "$@" || exit $? fi -# Check formatting -echo "📐 Checking format..." -deno fmt --check --quiet 2>/dev/null -FMT_EXIT=$? -if [ $FMT_EXIT -ne 0 ]; then - echo "❌ Formatting issues found. Run 'deno fmt' to fix." - exit 1 -fi -echo "✅ Format OK" +# Get the current branch name +BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null) -# Lint -echo "🔎 Linting..." -deno lint --quiet 2>/dev/null -LINT_EXIT=$? -if [ $LINT_EXIT -ne 0 ]; then - echo "❌ Lint errors found." - exit 1 +if [ "$BRANCH" = "gitbutler/workspace" ]; then + echo "" + echo "GITBUTLER_ERROR: Cannot commit directly to gitbutler/workspace branch." + echo "" + echo "GitButler manages commits on this branch. Please use GitButler to commit your changes:" + echo " - Use the GitButler app to create commits" + echo " - Or run 'but commit' from the command line" + echo "" + echo "If you want to exit GitButler mode and use normal git:" + echo " - Run 'but teardown' to switch to a regular branch" + echo " - Or directly checkout another branch: git checkout " + echo "" + echo "If you no longer have the GitButler CLI installed, you can simply remove this hook and checkout another branch:" + printf ' rm "%s/pre-commit"\n' "$HOOKS_DIR" + echo "" + exit 1 fi -echo "✅ Lint OK" -# Type check -echo "📋 Type checking..." -deno check main.ts lib/*.ts 2>/dev/null -CHECK_EXIT=$? -if [ $CHECK_EXIT -ne 0 ]; then - echo "❌ Type errors found." - exit 1 +# Not on workspace branch - run user's original hook if it exists +if [ -x "$HOOKS_DIR/pre-commit-user" ]; then + echo "" + echo "WARNING: GitButler's pre-commit hook is still installed but you're not on gitbutler/workspace." + echo "If you're no longer using GitButler, you can restore your original hook:" + printf ' mv "%s/pre-commit-user" "%s/pre-commit"\n' "$HOOKS_DIR" "$HOOKS_DIR" + echo "" fi -echo "✅ Types OK" -echo "✅ All checks passed!" +exit 0 diff --git a/.githooks/pre-commit-user b/.githooks/pre-commit-user new file mode 100755 index 0000000..339bbb8 --- /dev/null +++ b/.githooks/pre-commit-user @@ -0,0 +1,44 @@ +#!/bin/sh +# Pre-commit hook: run Deno checks on staged files + +echo "🔍 Running pre-commit checks..." + +# Get staged .ts files (excluding node_modules, .opencode) +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.ts$' || true) + +if [ -z "$STAGED_FILES" ]; then + echo "✨ No TypeScript files staged, skipping checks." + exit 0 +fi + +# Check formatting +echo "📐 Checking format..." +deno fmt --check --quiet 2>/dev/null +FMT_EXIT=$? +if [ $FMT_EXIT -ne 0 ]; then + echo "❌ Formatting issues found. Run 'deno fmt' to fix." + exit 1 +fi +echo "✅ Format OK" + +# Lint +echo "🔎 Linting..." +deno lint --quiet 2>/dev/null +LINT_EXIT=$? +if [ $LINT_EXIT -ne 0 ]; then + echo "❌ Lint errors found." + exit 1 +fi +echo "✅ Lint OK" + +# Type check +echo "📋 Type checking..." +deno check main.ts lib/*.ts 2>/dev/null +CHECK_EXIT=$? +if [ $CHECK_EXIT -ne 0 ]; then + echo "❌ Type errors found." + exit 1 +fi +echo "✅ Types OK" + +echo "✅ All checks passed!" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72a625b..7b39526 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: deno-version: v2.x - name: Cache Deno dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/deno key: deno-${{ hashFiles('deno.lock') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f56a8cc..da55cab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Semantic Release id: semantic - uses: cycjimmy/semantic-release-action@v4 + uses: cycjimmy/semantic-release-action@v6 with: extra_plugins: | @semantic-release/exec diff --git a/README.md b/README.md index ff19181..fdd0435 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ ocv dash --all # Show all items ocv sessions # List sessions matching directory path ocv session # Detailed info for a specific session ocv search # Search sessions by title or directory +ocv rename --from-dir -d # Batch-rename session directory ocv --help # Top-level help ocv --help # Per-command help ocv --version # Show version @@ -89,6 +90,7 @@ ocv sessions -o json # Sessions as JSON ocv dash --output json # Dashboard data as JSON ocv overview --output json # Overview as JSON ocv search --output json # Search results as JSON +ocv rename --from-dir ... -d ... --output json # Rename result as JSON ``` ### Excluding directories @@ -128,14 +130,15 @@ The `sessions` and `search` commands show a "Type" column: ## Commands -| Command | Description | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `(no args)` / `overview` | Per-directory session overview table | -| `stats` | Full statistics: sessions (active/archived), projects, tokens, cost, most-used model, app version range | -| `dash` | ANSI dashboard with bars per directory, model, provider, weekly activity. Supports `--top`, `--all`, `--name`, `--exclude` | -| `sessions ` | Filtered session list matching a directory path | -| `session ` | Single session detail with messages and todo breakdown | -| `search ` | Full-text search over session titles and directories | +| Command | Description | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `(no args)` / `overview` | Per-directory session overview table | +| `stats` | Full statistics: sessions (active/archived), projects, tokens, cost, most-used model, app version range | +| `dash` | ANSI dashboard with bars per directory, model, provider, weekly activity. Supports `--top`, `--all`, `--name`, `--exclude` | +| `sessions ` | Filtered session list matching a directory path | +| `session ` | Single session detail with messages and todo breakdown | +| `search ` | Full-text search over session titles and directories | +| `rename --from-dir -d ` | Batch-rename session directory and path fields when project moves | ## Semantic versioning @@ -163,8 +166,8 @@ binaries attached. Reads the OpenCode SQLite database (`opencode.db`) directly via `@db/sqlite`. Queries aggregate token usage, session counts, model usage, version ranges, and -per-project activity from the session, message, and part tables. All data is -read-only — never writes to the database. +per-project activity from the session, message, and part tables. Most commands +are read-only; the `rename` command writes to update directory and path fields. ## Build diff --git a/deno.lock b/deno.lock index aaa5885..566f0b2 100644 --- a/deno.lock +++ b/deno.lock @@ -6,6 +6,7 @@ "jsr:@cliffy/internal@1.1.1": "1.1.1", "jsr:@cliffy/table@1.1.1": "1.1.1", "jsr:@db/sqlite@0.12": "0.12.0", + "jsr:@db/sqlite@0.12.0": "0.12.0", "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@std/assert@0.217": "0.217.0", "jsr:@std/encoding@1": "1.0.10", diff --git a/lib/db.ts b/lib/db.ts index cbad763..06dffbb 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -6,6 +6,7 @@ import type { ModelCostRow, ModelStat, ProviderStat, + RenameResult, SessionDetail, SessionListRow, SessionRow, @@ -14,14 +15,19 @@ import type { } from "./types.ts"; /** - * Open the opencode SQLite DB in read-only mode. + * Open the opencode SQLite DB. * Enables int64 support so timestamps (>2^31) aren't truncated. + * Defaults to read-only mode; pass readonly=false for write access. * Throws with descriptive message if the file doesn't exist or can't be opened. */ -export function openDb(dbPath: string): Database { +export function openDb(dbPath: string, readonly: boolean = true): Database { try { - const db = new Database(dbPath, { readonly: true, int64: true }); - db.exec("PRAGMA journal_mode=WAL;"); + const db = new Database(dbPath, { readonly, int64: true }); + if (!readonly) { + db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;"); + } else { + db.exec("PRAGMA journal_mode=WAL;"); + } return db; } catch (cause) { throw new Error(`Cannot open DB at ${dbPath}: ${cause}`); @@ -543,3 +549,56 @@ export function searchSessions( `).all(pattern, pattern), ) as SessionListRow[]; } + +/** + * Batch-rename session directory and path. + * Updates exact matches and subdirectory prefixes on the directory column. + * For the path field, replaces the old directory prefix with the new one + * when the path starts with the oldDir prefix. + * Runs inside a BEGIN/COMMIT transaction for atomicity. + */ +export function renameDirectory( + db: Database, + oldDir: string, + newDir: string, +): RenameResult { + if (!oldDir || !newDir) { + throw new Error("oldDir and newDir must be non-empty strings"); + } + + // Normalize trailing slashes + const oldDirNorm = oldDir.replace(/\/+$/, ""); + const newDirNorm = newDir.replace(/\/+$/, ""); + + db.exec("BEGIN"); + + try { + // Update exact directory matches + const exactChanges = db.prepare( + "UPDATE session SET directory = ? WHERE directory = ?", + ).run(newDirNorm, oldDirNorm); + + // Update subdirectory matches (directory LIKE oldDirNorm + "/%") + const subChanges = db.prepare( + "UPDATE session SET directory = REPLACE(directory, ?, ?) WHERE directory LIKE ?", + ).run(`${oldDirNorm}/`, `${newDirNorm}/`, `${oldDirNorm}/%`); + + // Update path field where it starts with oldDir prefix + db.prepare( + "UPDATE session SET path = REPLACE(path, ?, ?) WHERE path LIKE ?", + ).run(oldDirNorm, newDirNorm, `${oldDirNorm}%`); + + db.exec("COMMIT"); + + const affected = exactChanges + subChanges; + + return { + old_directory: oldDirNorm, + new_directory: newDirNorm, + affected_sessions: affected, + }; + } catch (cause) { + db.exec("ROLLBACK"); + throw new Error(`Failed to rename directory: ${cause}`); + } +} diff --git a/lib/format.ts b/lib/format.ts index 47ba785..f67a599 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -1,6 +1,7 @@ import type { DbStats, DirectoryOverviewRow, + RenameResult, SessionListRow, SessionRow, TodoSummary, @@ -302,6 +303,19 @@ export function formatDbStats(stats: DbStats): string { return lines.join("\n"); } +/** + * Format the result of a rename operation. + */ +export function formatRenameResult(result: RenameResult): string { + const lines: string[] = []; + lines.push("Directory renamed successfully"); + lines.push("─".repeat(40)); + lines.push(` Old directory: ${result.old_directory}`); + lines.push(` New directory: ${result.new_directory}`); + lines.push(` Sessions affected: ${result.affected_sessions}`); + return lines.join("\n"); +} + /** * Print usage information. */ diff --git a/lib/types.ts b/lib/types.ts index d8aee3e..85c969c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -156,3 +156,9 @@ export interface DbStats { version_min: string | null; version_max: string | null; } + +export interface RenameResult { + old_directory: string; + new_directory: string; + affected_sessions: number; +} diff --git a/main.ts b/main.ts index 64e6557..ed2312b 100644 --- a/main.ts +++ b/main.ts @@ -6,6 +6,7 @@ import { getSessionDetail, getSessionsByDirectory, openDb, + renameDirectory, searchSessions, } from "./lib/db.ts"; import { showDashboard } from "./lib/dashboard.ts"; @@ -14,6 +15,7 @@ import { VERSION } from "./version.ts"; import { formatDbStats, formatOverview, + formatRenameResult, formatSearchResults, formatSessionDetail, formatSessionList, @@ -157,6 +159,31 @@ async function main() { Deno.exit(1); } }) + .command("rename", "Rename session directory (batch)") + .option( + "--from-dir ", + "Current directory path to match", + { required: true }, + ) + .option( + "-d, --directory ", + "New directory path", + { required: true }, + ) + .action((options) => { + const spinner = showSpinner("Renaming sessions..."); + try { + const db = openDb(dbPath, false); + const result = renameDirectory(db, options.fromDir, options.directory); + spinner.stop(); + formatOutput(result, options.output, formatRenameResult); + db.close(); + } catch (cause) { + spinner.stop(); + console.error(`Error: ${cause}`); + Deno.exit(1); + } + }) .command("stats", "Show overall database statistics") .action((options) => { const spinner = showSpinner("Loading data...");