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
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.1.0"
version = "2.2.0"
authors = ["Kyle W. Rader"]
description = "Loki: 🚀 A Git productivity tool"
homepage = "https://github.com/kyle-rader/loki-cli"
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,13 @@ Create a new worktree as a sibling directory and set up a branch with upstream t

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

# Check out an existing remote branch into a worktree
❯ lk w a review-feature -b users/teammate/cool-feature
```

The worktree is created at `<parent>/<repo>_<name>` (e.g., `~/repos/my-project_fix-auth`).
If `-b` points to an existing remote branch, the branch is checked out directly
instead of creating a new one.

**Flags:**
- `--base` / `-b` — Base ref (default: `origin/main`, env: `LOKI_WORKTREE_BASE`)
Expand Down
28 changes: 28 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,34 @@ where
Ok(())
}

/// Execute the git command with stdout suppressed. Stderr is captured and
/// included in error messages. Use when stdout must stay clean for piping.
pub fn git_command_status_quiet<I, S>(name: &str, args: I) -> Result<(), String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let args: Vec<_> = args.into_iter().collect();
let args_display: Vec<_> = args.iter().map(|a| a.as_ref().to_string_lossy()).collect();

let output = Command::new(GIT)
.args(&args)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("{name} failed to run: git {}\n{e}", args_display.join(" ")))?;

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

/// Execute the git command and return an iterator over its output lines (both stdout and stderr) as they arrive.
pub fn git_command_stream<I, S>(name: &str, args: I) -> Result<impl Iterator<Item = String>, String>
where
Expand Down
75 changes: 62 additions & 13 deletions src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};

use colored::Colorize;

use crate::git::{git_command_iter, git_command_status, git_commands_status};
use crate::git::{git_command_iter, git_command_lines, git_command_status_quiet};
use crate::vars::LOKI_NEW_PREFIX;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -63,6 +63,31 @@ fn normalize_path(path: &str) -> String {
path.replace('\\', "/")
}

/// Checks if a ref matches an existing remote branch on origin.
/// Returns the full remote ref (e.g. `origin/branch-name`) if found.
fn find_remote_branch(name: &str) -> Option<String> {
// Check common forms: bare name, origin/name, or full ref
let candidates = if name.starts_with("origin/") {
vec![name.to_string()]
} else if name.starts_with("refs/") {
vec![name.strip_prefix("refs/heads/").unwrap_or(name).to_string()]
} else {
vec![name.to_string()]
};

for candidate in &candidates {
let lines = git_command_lines(
"ls-remote",
vec!["ls-remote", "--heads", "origin", candidate.as_str()],
)
.unwrap_or_default();
if !lines.is_empty() {
return Some(format!("origin/{candidate}"));
}
}
None
}

/// A parsed worktree entry from porcelain output.
struct WorktreeEntry {
path: String,
Expand Down Expand Up @@ -122,7 +147,8 @@ fn list_worktree_entries() -> Result<Vec<WorktreeEntry>, String> {
// ---------------------------------------------------------------------------

/// Creates a worktree at `<parent>/<repo>_<name>`, then creates and pushes a
/// branch with optional prefix. Outputs `cd <path>` to stdout for piping.
/// branch with optional prefix. If the base ref is an existing remote branch,
/// checks it out directly instead. Outputs `cd <path>` to stdout for piping.
pub fn worktree_add(name: &[String], base: &str, prefix: Option<&str>) -> Result<(), String> {
if name.is_empty() {
return Err(String::from("name cannot be empty."));
Expand All @@ -137,13 +163,38 @@ pub fn worktree_add(name: &[String], base: &str, prefix: Option<&str>) -> Result
return Err(format!("Worktree path already exists: {wt_path_str}"));
}

// Check if the base is an existing remote branch to check out directly
if let Some(remote_ref) = find_remote_branch(base) {
eprintln!(
"Found existing branch {} — checking out into worktree",
base.cyan()
);
git_command_status_quiet("fetch", vec!["fetch", "origin"])?;
git_command_status_quiet(
"worktree add",
vec![
"worktree",
"add",
"--track",
"-b",
base,
wt_path_str.as_ref(),
remote_ref.as_str(),
],
)?;

eprintln!("\n{}", "Worktree ready!".green().bold());
println!("cd {wt_path_str}");
return Ok(());
}

// New branch flow
eprintln!("Creating worktree at {}", wt_path_str.green());
git_command_status(
git_command_status_quiet(
"worktree add",
vec!["worktree", "add", wt_path_str.as_ref(), base],
)?;

// Set process cwd so branch creation runs inside the new worktree
std::env::set_current_dir(&wt_path)
.map_err(|e| format!("Failed to enter worktree directory: {e}"))?;

Expand All @@ -152,13 +203,11 @@ pub fn worktree_add(name: &[String], base: &str, prefix: Option<&str>) -> Result
name = format!("{prefix}{name}");
}

git_commands_status(vec![
("create branch", vec!["switch", "--create", name.as_str()]),
(
"push to origin",
vec!["push", "--set-upstream", "origin", name.as_str()],
),
])?;
git_command_status_quiet("create branch", vec!["switch", "--create", name.as_str()])?;
git_command_status_quiet(
"push to origin",
vec!["push", "--set-upstream", "origin", name.as_str()],
)?;

eprintln!("\n{}", "Worktree ready!".green().bold());
println!("cd {wt_path_str}");
Expand Down Expand Up @@ -221,7 +270,7 @@ pub fn worktree_remove(name: &[String], force: bool, prefix: Option<&str>) -> Re
remove_args.push("--force");
}
remove_args.push(wt_path_str.as_ref());
git_command_status("worktree remove", remove_args)?;
git_command_status_quiet("worktree remove", remove_args)?;
eprintln!("Removed worktree {}", wt_path_str.red());

// Best-effort branch cleanup — may already be gone
Expand All @@ -230,7 +279,7 @@ pub fn worktree_remove(name: &[String], force: bool, prefix: Option<&str>) -> Re
None => name,
};

match git_command_status("delete branch", vec!["branch", "-D", branch.as_str()]) {
match git_command_status_quiet("delete branch", vec!["branch", "-D", branch.as_str()]) {
Ok(()) => eprintln!("Deleted branch {}", branch.red()),
Err(_) => eprintln!(
"Branch {} not found locally (may already be deleted)",
Expand Down
Loading