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
15 changes: 15 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,21 @@ fn cmd_up(args: cli::UpArgs) -> Result<()> {

// Resume path: session already exists — restart/skip services idempotently.
if let Some(existing) = guard.state.find_session(&slug).cloned() {
// Slugs are sanitized branch names (feat/foo → feat-foo), so two
// different branches can collide on one slug. Addressing the session
// by its slug or by its exact branch resumes it; anything else would
// silently resume the wrong branch.
if let Some(requested) = args.slug.as_deref() {
if requested != existing.slug && existing.branch != branch {
return Err(anyhow::anyhow!(
"slug '{}' is already used by branch '{}' (requested branch '{}'); run `ecluse down {}` first or pick a different branch name",
slug,
existing.branch,
branch,
slug
));
}
}
log.step("Looking for existing session...");
log.detail(&format!(
"found session '{}' (slot {}) — reusing worktree",
Expand Down
136 changes: 125 additions & 11 deletions src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,28 @@ impl WorktreeManager {
}

pub fn create(&self, path: &Path, branch: &str) -> Result<()> {
// Try to create branch from HEAD; if it exists already, reuse it
let branch_exists = Command::new("git")
.args(["branch", "--list", branch])
.current_dir(&self.project_root)
.output()
.context("failed to run git branch --list")?
.stdout
.iter()
.any(|&b| b != b'\n');

let status = if branch_exists {
let status = if self.local_branch_exists(branch)? {
// Reuse the existing local branch.
Command::new("git")
.args(["worktree", "add"])
.arg(path)
.arg(branch)
.current_dir(&self.project_root)
.status()
.context("failed to run git worktree add")?
} else if self.remote_branch_exists(branch) {
// The branch exists on origin but not locally — create a tracking
// branch from it. Forking a same-named branch off HEAD here would
// silently put the session on the wrong base.
Command::new("git")
.args(["worktree", "add", "--track", "-b", branch])
.arg(path)
.arg(format!("origin/{}", branch))
.current_dir(&self.project_root)
.status()
.context("failed to run git worktree add --track")?
} else {
// Brand-new branch from HEAD.
Command::new("git")
.args(["worktree", "add", "-b"])
.arg(branch)
Expand All @@ -61,6 +64,31 @@ impl WorktreeManager {
Ok(())
}

fn local_branch_exists(&self, branch: &str) -> Result<bool> {
let output = Command::new("git")
.args(["branch", "--list", branch])
.current_dir(&self.project_root)
.output()
.context("failed to run git branch --list")?;
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
}

/// True when origin/<branch> exists as a local remote-tracking ref
/// (no network access — uses whatever the last fetch brought in).
fn remote_branch_exists(&self, branch: &str) -> bool {
Command::new("git")
.args([
"rev-parse",
"--verify",
"--quiet",
&format!("refs/remotes/origin/{}", branch),
])
.current_dir(&self.project_root)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}

pub fn remove(&self, path: &Path) -> Result<()> {
let status = Command::new("git")
.args(["worktree", "remove", "--force"])
Expand Down Expand Up @@ -273,7 +301,9 @@ mod tests {
.current_dir(dir)
.output()
.unwrap();
// Disable signing: fixture commits must work without any signing setup.
Command::new("git")
.args(["-c", "commit.gpgsign=false"])
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
Expand Down Expand Up @@ -382,6 +412,90 @@ mod tests {
wt.remove(&path).unwrap();
}

/// Run git with signing disabled and a fixed identity; panic on failure
/// so fixture problems surface as the real error, not a later assert.
fn git_ok(dir: &std::path::Path, args: &[&str]) -> String {
let out = Command::new("git")
.args(["-c", "commit.gpgsign=false"])
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}

// A branch that exists only on origin must be checked out as a tracking
// branch, not forked from HEAD under the same name.
#[test]
fn create_tracks_remote_only_branch() {
let upstream = TempDir::new().unwrap();
git_ok(upstream.path(), &["init"]);
git_ok(upstream.path(), &["commit", "--allow-empty", "-m", "init"]);
// feat-remote points at the initial commit; the default branch then
// moves ahead, so the clone's HEAD differs from origin/feat-remote.
git_ok(upstream.path(), &["branch", "feat-remote"]);
git_ok(
upstream.path(),
&["commit", "--allow-empty", "-m", "default moves ahead"],
);

let clone_parent = TempDir::new().unwrap();
let clone_path = clone_parent.path().join("clone");
git_ok(
clone_parent.path(),
&["clone", upstream.path().to_str().unwrap(), "clone"],
);

// feat-remote exists on origin only — not as a local branch.
let wt = WorktreeManager::new(clone_path.clone());
assert!(!wt.local_branch_exists("feat-remote").unwrap());
assert!(wt.remote_branch_exists("feat-remote"));

let config = make_config_for_worktree();
let path = wt.worktree_path(&config, "feat-remote");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
wt.create(&path, "feat-remote").unwrap();

// The worktree's branch must track origin/feat-remote and point at
// the same commit.
let upstream_ref = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "feat-remote@{upstream}"])
.current_dir(&path)
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&upstream_ref.stdout).trim(),
"origin/feat-remote"
);
let local_head = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&path)
.output()
.unwrap();
let remote_head = Command::new("git")
.args(["rev-parse", "origin/feat-remote"])
.current_dir(&clone_path)
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&local_head.stdout).trim(),
String::from_utf8_lossy(&remote_head.stdout).trim(),
"worktree must start at the remote branch tip, not at HEAD"
);

wt.remove(&path).unwrap();
}

// ── is_inside_git_worktree ────────────────────────────────────────────────

#[test]
Expand Down
28 changes: 28 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ fn setup_repo(dir: &std::path::Path) {
.current_dir(dir)
.output()
.unwrap();
// Disable signing: fixture commits must work without any signing setup.
Command::new("git")
.args(["-c", "commit.gpgsign=false"])
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
Expand Down Expand Up @@ -448,3 +450,29 @@ fn up_without_init_errors() {
stderr(&out)
);
}

#[test]
fn up_with_colliding_branch_name_errors() {
let repo = tmp_repo();
ecluse(repo.path(), &["init", "--mode", "host", "--yes"]);

// Session created from branch 'feat-foo' (slug feat-foo).
let out = ecluse(repo.path(), &["up", "feat-foo"]);
assert!(out.status.success(), "{}", stderr(&out));

// 'feat/foo' sanitizes to the same slug but is a different branch —
// resuming silently would put the caller on the wrong branch.
let out = ecluse(repo.path(), &["up", "feat/foo"]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("already used by branch"),
"got: {}",
stderr(&out)
);

// Addressing the session by its slug still resumes normally.
let out = ecluse(repo.path(), &["up", "feat-foo"]);
assert!(out.status.success(), "{}", stderr(&out));

ecluse(repo.path(), &["down", "--delete-worktree", "feat-foo"]);
}
Loading