1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
//! Squash-merge a work branch into main even when `store.root` is a
//! bare gitdir. Non-bare roots run the squash directly. Bare roots
//! cannot host working-tree-required commands (`git merge --squash`
//! refuses with "this operation must be run in a work tree"), so we
//! provision an ephemeral detached worktree at `<root>/.balls/local/
//! squash-<pid>`, do the squash there, and update `refs/heads/<main>`
//! from the bare gitdir afterward. See bl-56f4: bare repos with linked
//! `.balls-worktrees/` checkouts are a designed-for layout, but the
//! direct-squash code path silently broke them.
use crate::error::Result;
use crate::git;
use crate::store::Store;
use std::path::{Path, PathBuf};
/// Squash-merge `branch` into `store.root`'s configured main branch,
/// producing a single commit with `msg`. Returns the new commit's SHA,
/// or `None` when the squash produced no staged changes (a "no-code"
/// review — the caller decides whether to skip the commit and emit a
/// `no-code` state-branch marker).
pub fn squash_into_main(
store: &Store,
branch: &str,
msg: &str,
main_branch: &str,
) -> Result<Option<String>> {
if squashes_in_place(store, main_branch)? {
return squash_in_place(&store.root, branch, msg);
}
squash_in_detached_worktree(store, branch, msg, main_branch)
}
/// In-place squash (`git merge --squash` in the repo-root work tree,
/// committing onto whatever is checked out there) is correct *only*
/// when the integration branch is exactly the branch checked out at a
/// non-bare root. A bare root has no work tree; a `target_branch`
/// pointing somewhere other than the checkout would land the squash
/// on the wrong branch and disturb the user's tree. Both of those
/// cases route through a detached worktree pinned at `main_branch`
/// instead. Unset `target_branch` on a normal repo ⇒ in-place,
/// byte-identical to before the `integration_branch` seam. `rewind_main`
/// keys off the same predicate so a failed review unwinds the branch
/// it actually moved.
pub(crate) fn squashes_in_place(store: &Store, main_branch: &str) -> Result<bool> {
if is_bare_repo(&store.root)? {
return Ok(false);
}
Ok(git::git_current_branch(&store.root)? == main_branch)
}
fn squash_in_place(dir: &Path, branch: &str, msg: &str) -> Result<Option<String>> {
git::git_merge_squash(dir, branch)?;
if git::has_staged_changes(dir)? {
git::git_commit(dir, msg)?;
Ok(Some(git::git_resolve_sha(dir, "HEAD")?))
} else {
Ok(None)
}
}
fn squash_in_detached_worktree(
store: &Store,
branch: &str,
msg: &str,
main: &str,
) -> Result<Option<String>> {
let tmp = squash_worktree_path(store);
scrub_path(&store.root, &tmp);
if let Some(parent) = tmp.parent() {
std::fs::create_dir_all(parent)?;
}
worktree_add_detach(&store.root, &tmp, main)?;
let result = squash_in_place(&tmp, branch, msg);
if let Ok(Some(sha)) = result.as_ref() {
update_ref(&store.root, &format!("refs/heads/{main}"), sha)?;
}
scrub_path(&store.root, &tmp);
result
}
/// Process-id-suffixed temp path so concurrent `bl review` invocations
/// in the same store cannot collide on the worktree directory.
fn squash_worktree_path(store: &Store) -> PathBuf {
store.local_dir().join(format!("squash-{}", std::process::id()))
}
/// Best-effort cleanup: detach git's record of the worktree first
/// (otherwise a leftover entry in `worktrees/` blocks a re-add at the
/// same path), then remove the directory itself in case `git` left
/// admin files behind. Errors are intentionally swallowed — a stale
/// path will be cleaned by the next squash, and surfacing this error
/// to the caller would mask the real failure.
fn scrub_path(repo: &Path, path: &Path) {
if path.exists() {
let _ = git::git_worktree_remove(repo, path, true);
}
if path.exists() {
let _ = std::fs::remove_dir_all(path);
}
}
/// True when `dir`'s gitdir has `core.bare = true`. Bare gitdirs
/// reject working-tree commands; callers must route those ops through
/// a real worktree. Shared with `Store::discover` (bl-8cf7): a bare
/// root has no work tree, so discovery can't lean on `--show-toplevel`.
pub(crate) fn is_bare_repo(dir: &Path) -> Result<bool> {
Ok(git::run_git_ok(dir, &["rev-parse", "--is-bare-repository"])?.trim() == "true")
}
/// `git worktree add --detach <path> <ref>`: create a worktree at
/// `path` with a detached HEAD pointing at `ref`'s tip. Detached so
/// the squash commit doesn't claim the main branch — we plumb the
/// resulting SHA into `refs/heads/<main>` separately.
fn worktree_add_detach(dir: &Path, path: &Path, refname: &str) -> Result<()> {
let path_str = path.to_string_lossy().to_string();
git::run_git_ok(dir, &["worktree", "add", "--detach", &path_str, refname])?;
Ok(())
}
/// `git update-ref <name> <sha>`: move a ref to the given SHA. Used
/// to fast-forward main from the bare gitdir after the detached
/// worktree produced the squash commit.
fn update_ref(dir: &Path, name: &str, sha: &str) -> Result<()> {
git::run_git_ok(dir, &["update-ref", name, sha])?;
Ok(())
}
#[cfg(test)]
#[path = "bare_squash_tests.rs"]
mod tests;