Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .githooks/post-checkout
Original file line number Diff line number Diff line change
@@ -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
67 changes: 33 additions & 34 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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 <branch>"
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
44 changes: 44 additions & 0 deletions .githooks/pre-commit-user
Original file line number Diff line number Diff line change
@@ -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!"
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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') }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ ocv dash --all # Show all items
ocv sessions <path> # List sessions matching directory path
ocv session <id> # Detailed info for a specific session
ocv search <query> # Search sessions by title or directory
ocv rename --from-dir <old-dir> -d <new-dir> # Batch-rename session directory
ocv --help # Top-level help
ocv <command> --help # Per-command help
ocv --version # Show version
Expand All @@ -89,6 +90,7 @@ ocv sessions <path> -o json # Sessions as JSON
ocv dash --output json # Dashboard data as JSON
ocv overview --output json # Overview as JSON
ocv search <query> --output json # Search results as JSON
ocv rename --from-dir ... -d ... --output json # Rename result as JSON
```

### Excluding directories
Expand Down Expand Up @@ -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 <path>` | Filtered session list matching a directory path |
| `session <id>` | Single session detail with messages and todo breakdown |
| `search <query>` | 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 <path>` | Filtered session list matching a directory path |
| `session <id>` | Single session detail with messages and todo breakdown |
| `search <query>` | Full-text search over session titles and directories |
| `rename --from-dir <old> -d <new>` | Batch-rename session directory and path fields when project moves |

## Semantic versioning

Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 63 additions & 4 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ModelCostRow,
ModelStat,
ProviderStat,
RenameResult,
SessionDetail,
SessionListRow,
SessionRow,
Expand All @@ -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}`);
Expand Down Expand Up @@ -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}`);
}
}
14 changes: 14 additions & 0 deletions lib/format.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
DbStats,
DirectoryOverviewRow,
RenameResult,
SessionListRow,
SessionRow,
TodoSummary,
Expand Down Expand Up @@ -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.
*/
Expand Down
6 changes: 6 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading