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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
//! The Run's worktree: a sibling of the launch repository, on the Issue
//! branch, removed together with the local Issue branch when dropped unless
//! it is kept.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::git::Git;
use crate::progress;
/// How merging the Base branch into the Issue branch went.
#[derive(Debug, PartialEq, Eq)]
pub enum Merge {
/// Merged, or nothing to merge.
Clean,
/// Conflicted; the merge is left in progress for a conflict Repair.
Conflicted,
}
pub struct Worktree {
launch: Git,
branch: String,
git: Git,
/// Leave the worktree and local Issue branch in place when dropped.
kept: bool,
}
impl Worktree {
/// Create `branch` fresh from `origin/<base>` in a new worktree next to the
/// launch repository's root, named `<repo>-<branch>`.
pub fn create_fresh(launch: &Git, repo: &str, branch: &str, base: &str) -> Result<Self> {
launch.run(&["fetch", "origin", base])?;
Self::add(
launch,
repo,
branch,
&["-b", branch],
&format!("origin/{base}"),
)
}
/// Check out the existing `branch` from origin in a new worktree next to
/// the launch repository's root, named `<repo>-<branch>`. `origin/<base>`
/// is fetched too, for the review fixed point. A local `branch`, if any, is
/// reset to origin's: `branch::select` has checked they already match.
pub fn continue_existing(launch: &Git, repo: &str, branch: &str, base: &str) -> Result<Self> {
launch.run(&["fetch", "origin", base, branch])?;
Self::add(
launch,
repo,
branch,
&["-B", branch],
&format!("origin/{branch}"),
)
}
fn add(
launch: &Git,
repo: &str,
branch: &str,
branch_args: &[&str],
start: &str,
) -> Result<Self> {
let root = PathBuf::from(launch.run(&["rev-parse", "--show-toplevel"])?);
let path = root
.parent()
.context("the repository root has no parent directory")?
.join(format!("{repo}-{branch}"));
let path_arg = path.to_str().context("worktree path is not UTF-8")?;
progress::step(format_args!(
"creating worktree {} on {branch} from {start}",
path.display()
));
let mut args = vec!["worktree", "add"];
args.extend_from_slice(branch_args);
args.extend([path_arg, start]);
launch.run(&args)?;
Ok(Worktree {
launch: Git::new(root),
branch: branch.to_string(),
git: Git::new(path),
kept: false,
})
}
pub fn path(&self) -> &Path {
self.git.dir()
}
pub fn branch(&self) -> &str {
&self.branch
}
pub fn git(&self) -> &Git {
&self.git
}
/// Push the Issue branch to origin (a no-op if it is already there).
/// The target repo's hooks are skipped: the session runs the tests and CI
/// gates the PR, so a local hook doesn't decide whether work reaches
/// origin.
pub fn push(&self) -> Result<()> {
progress::step(format_args!("pushing {}", self.branch));
self.git
.run(&["push", "--no-verify", "origin", &self.branch])?;
Ok(())
}
/// Let go of the worktree without removing it or the local Issue branch,
/// for work that may exist nowhere else. Dropping it then says where they
/// are and the branch's head commit.
pub fn keep(mut self) {
self.kept = true;
}
/// The Issue branch's head commit.
pub fn head(&self) -> Result<String> {
self.git.run(&["rev-parse", "HEAD"])
}
/// Fetch `origin/<base>` and merge it into the Issue branch: a merge,
/// never a rebase, so pushing it is always a fast-forward. `--ff` keeps a
/// user's `merge.ff = only` from turning a clean merge into an error.
pub fn merge_base_branch(&self, base: &str) -> Result<Merge> {
progress::step(format_args!("merging origin/{base} into {}", self.branch));
self.git.run(&["fetch", "origin", base])?;
match self
.git
.run(&["merge", "--no-edit", "--ff", &format!("origin/{base}")])
{
Ok(_) => Ok(Merge::Clean),
Err(_) if self.merge_in_progress()? => Ok(Merge::Conflicted),
Err(error) => Err(error),
}
}
/// Fail unless `origin/<base>` is fully merged into the Issue branch, e.g.
/// after a conflict Repair that left the merge unfinished or aborted it.
pub fn ensure_base_branch_merged(&self, base: &str) -> Result<()> {
if self.merge_in_progress()? {
bail!("the merge of origin/{base} is still in progress");
}
if !self.base_branch_merged(base)? {
bail!("origin/{base} is not merged into {}", self.branch);
}
Ok(())
}
/// Fetch `origin/<base>` and say whether it has commits the Issue branch
/// has not merged yet.
pub fn base_branch_moved(&self, base: &str) -> Result<bool> {
self.git.run(&["fetch", "origin", base])?;
Ok(!self.base_branch_merged(base)?)
}
/// Whether the fetched `origin/<base>` is fully merged into the Issue
/// branch.
fn base_branch_merged(&self, base: &str) -> Result<bool> {
let upstream = format!("origin/{base}");
self.git
.succeeds(&["merge-base", "--is-ancestor", &upstream, "HEAD"])
}
/// Whether a merge is in progress in the worktree.
pub fn merge_in_progress(&self) -> Result<bool> {
self.git
.succeeds(&["rev-parse", "-q", "--verify", "MERGE_HEAD"])
}
}
impl Drop for Worktree {
fn drop(&mut self) {
let path = self.path().to_string_lossy().into_owned();
if self.kept {
let head = self
.head()
.unwrap_or_else(|error| format!("an unknown commit ({error:#})"));
progress::step(format_args!(
"keeping the worktree {path} and local branch {} at {head}",
self.branch
));
return;
}
progress::step(format_args!(
"cleaning up the worktree and local branch {}",
self.branch
));
// Each step is attempted even if the one before it failed.
let steps: [&[&str]; 2] = [
&["worktree", "remove", "--force", &path],
&["branch", "-D", &self.branch],
];
for step in steps {
if let Err(error) = self.launch.run(step) {
progress::step(format_args!("cleanup incomplete: {error:#}"));
}
}
}
}