Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "loki-cli"
version = "2.0.0"
version = "2.1.0"
authors = ["Kyle W. Rader"]
description = "Loki: 🚀 A Git productivity tool"
homepage = "https://github.com/kyle-rader/loki-cli"
Expand Down
98 changes: 89 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ Loki: 🚀 A Git productivity tool
Usage: lk <COMMAND>

Commands:
new Create a new branch from HEAD and push it to origin. Set a prefix with --prefix or the LOKI_NEW_PREFIX env var [aliases: n]
push Push the current branch to origin with --set-upstream [aliases: p]
pull Pull with --prune deleting local branches pruned from the remote
fetch Fetch with --prune deleting local branches pruned from the remote
save Add, commit, and push using a timestamp based commit message [aliases: s]
commit Commit local changes [aliases: c]
rebase Rebase the current branch onto the target branch after fetching
no-hooks Run any command without triggering any hooks [aliases: x]
help Print this message or the help of the given subcommand(s)
new Create a new branch from HEAD and push it to origin. Set a prefix with --prefix or the LOKI_NEW_PREFIX env var [aliases: n]
push Push the current branch to origin with --set-upstream [aliases: p]
pull Pull with --prune deleting local branches pruned from the remote
fetch Fetch with --prune deleting local branches pruned from the remote
save Add, commit, and push using a timestamp based commit message [aliases: s]
commit Commit local changes [aliases: c]
rebase Rebase the current branch onto the target branch after fetching
worktree Manage git worktrees [aliases: w]
no-hooks Run any command without triggering any hooks [aliases: x]
help Print this message or the help of the given subcommand(s)

Options:
-h, --help Print help
Expand Down Expand Up @@ -90,6 +91,85 @@ Execute a git commit without running any hooks
lk x -- commit -m "Update Readme without running hooks"
```

### `worktree`
Alias: `w`

Manage git worktrees for parallel development workflows. Subcommands:

#### `worktree add <name>` (alias: `a`)
Create a new worktree as a sibling directory and set up a branch with upstream tracking.

```sh
# Creates worktree at ../my-project_fix-auth, creates and pushes branch
❯ lk w a fix-auth

# With a custom base ref
❯ lk w a fix-auth --base origin/develop

# With a branch prefix (via flag or LOKI_NEW_PREFIX env var)
❯ lk w a fix-auth --prefix users/danigon/
```

The worktree is created at `<parent>/<repo>_<name>` (e.g., `~/repos/my-project_fix-auth`).

**Flags:**
- `--base` / `-b` — Base ref (default: `origin/main`, env: `LOKI_WORKTREE_BASE`)
- `--prefix` — Branch name prefix (env: `LOKI_NEW_PREFIX`)

#### `worktree remove [name]` (alias: `r`)
Remove a worktree and delete its local branch.

```sh
# From inside the worktree — name is inferred from the directory
~/repos/my-project_fix-auth ❯ lk w r

# Explicit name from anywhere
~/repos/my-project ❯ lk w r fix-auth

# Force remove a dirty worktree
❯ lk w r fix-auth --force
```

**Flags:**
- `--force` / `-f` — Force removal of dirty worktrees
- `--prefix` — Branch name prefix used during creation (env: `LOKI_NEW_PREFIX`)

#### `worktree list` (alias: `l`)
List all worktrees. Highlights the current worktree in green.

```sh
❯ lk w l
```

#### `worktree switch [name]` (alias: `s`)
Print a `cd` command for switching to a worktree. Designed for use with `eval`:

```bash
# bash/zsh — switch to a named worktree
eval "$(lk w s fix-auth)"

# PowerShell
lk w s fix-auth | Invoke-Expression

# Switch to the main worktree (no name)
eval "$(lk w s)"
```

#### Shell Wrappers

All worktree commands output `cd <path>` to stdout (info goes to stderr), so you can
pipe to your shell for automatic directory switching:

```powershell
# PowerShell
lk w a fix-auth | Invoke-Expression
```

```bash
# bash/zsh
eval "$(lk w a fix-auth)"
```

### `repo stats`
Analyze commits reachable from HEAD to see who has been landing work in a repository. All of the filtering flags operate on commit dates.

Expand Down
21 changes: 21 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,24 @@ where
{
Ok(git_command_iter(name, args)?.collect())
}

/// Execute a git command and return its stdout as a trimmed string.
/// Returns an error if the command fails (non-zero exit) or produces no stdout.
pub fn git_command_stdout<I, S>(name: &str, args: I) -> Result<String, String>
Comment thread
dggsax marked this conversation as resolved.
Outdated
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new(GIT)
.args(args)
.output()
.map_err(|err| format!("{name} failed: {err}"))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("{name} failed: {}", stderr.trim()));
}

let stdout = String::from_utf8(output.stdout).map_err(|e| format!("{e}"))?;
Ok(stdout.trim().to_string())
}
72 changes: 69 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
pub mod git;
pub mod pruning;
pub mod vars;
pub mod worktree;

use std::{
collections::HashMap,
Expand Down Expand Up @@ -31,7 +33,7 @@ fn styles() -> clap::builder::Styles {
.placeholder(AnsiColor::Cyan.on_default())
}

const NO_HOOKS: &str = "core.hooksPath=/dev/null";
use vars::{LOKI_NEW_PREFIX, NO_HOOKS};

#[derive(Debug, Parser)]
struct CommitOptions {
Expand Down Expand Up @@ -94,6 +96,53 @@ enum RepoSubcommand {
Stats(RepoStatsOptions),
}

#[derive(Debug, Subcommand)]
enum WorktreeSubcommand {
/// Create a new worktree and branch.
#[clap(visible_alias = "a")]
Add {
/// Optional prefix to prepend to the branch name.
#[clap(long, env = "LOKI_NEW_PREFIX")]
Comment thread
dggsax marked this conversation as resolved.
Outdated
prefix: Option<String>,

/// Base ref to create the worktree from.
#[clap(short, long, default_value = "origin/main", env = "LOKI_WORKTREE_BASE")]
Comment thread
dggsax marked this conversation as resolved.
Outdated
base: String,

/// Name parts joined with dashes to form the worktree and branch name.
name: Vec<String>,
},

/// Remove a worktree and its associated branch.
#[clap(visible_alias = "r")]
Remove {
/// Optional prefix used when the branch was created.
#[clap(long, env = "LOKI_NEW_PREFIX")]
Comment thread
dggsax marked this conversation as resolved.
Outdated
prefix: Option<String>,

/// Force removal of a dirty worktree.
#[clap(short, long)]
force: bool,

/// Worktree name. If omitted, inferred from the current directory.
name: Vec<String>,
},

/// List all worktrees.
#[clap(visible_alias = "l")]
List,

/// Print a cd command for switching to a worktree (use with eval).
///
/// bash/zsh: eval "$(lk w s <name>)"
/// PowerShell: lk w s <name> | Invoke-Expression
#[clap(visible_alias = "s")]
Switch {
/// Worktree name. If omitted, switches to the main worktree.
name: Vec<String>,
},
}

#[derive(Parser)]
#[clap(version, about, author, color = clap::ColorChoice::Auto, styles = styles())]
enum Cli {
Expand Down Expand Up @@ -159,13 +208,18 @@ enum Cli {
command: RepoSubcommand,
},

/// Manage git worktrees.
#[clap(visible_alias = "w")]
Worktree {
#[clap(subcommand)]
command: WorktreeSubcommand,
},

/// Push the main branch to the release branch.
#[clap(visible_alias = "r")]
Release,
}

const LOKI_NEW_PREFIX: &str = "LOKI_NEW_PREFIX";

fn main() -> Result<(), String> {
let cli = Cli::parse();

Expand All @@ -181,6 +235,18 @@ fn main() -> Result<(), String> {
Cli::Repo {
command: RepoSubcommand::Stats(options),
} => repo_stats(options),
Cli::Worktree { command } => match command {
WorktreeSubcommand::Add { name, base, prefix } => {
worktree::worktree_add(name, base, prefix.as_deref())
}
WorktreeSubcommand::Remove {
name,
force,
prefix,
} => worktree::worktree_remove(name, *force, prefix.as_deref()),
WorktreeSubcommand::List => worktree::worktree_list(),
WorktreeSubcommand::Switch { name } => worktree::worktree_switch(name),
},
Cli::Release => release(),
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/vars.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/// Environment variable for the branch name prefix used by `new` and `worktree add`.
pub const LOKI_NEW_PREFIX: &str = "LOKI_NEW_PREFIX";

/// Environment variable for the base ref used by `worktree add`.
pub const LOKI_WORKTREE_BASE: &str = "LOKI_WORKTREE_BASE";

/// Git config override that disables all hooks.
pub const NO_HOOKS: &str = "core.hooksPath=/dev/null";
Loading