crates/flotilla-core/src/providers/vcs/mod.rs
Line | Count | Source |
1 | | pub mod clone; |
2 | | pub mod git; |
3 | | pub mod git_worktree; |
4 | | pub mod wt; |
5 | | |
6 | | use std::path::Path; |
7 | | |
8 | | use async_trait::async_trait; |
9 | | use flotilla_protocol::CheckoutIntent; |
10 | | use tracing::warn; |
11 | | |
12 | | use crate::{ |
13 | | path_context::ExecutionEnvironmentPath, |
14 | | providers::{run, types::*, ChannelLabel, CommandRunner}, |
15 | | }; |
16 | | |
17 | | pub const TRUNK_NAMES: &[&str] = &["main", "master", "trunk"]; |
18 | | |
19 | | #[allow(dead_code)] |
20 | | #[async_trait] |
21 | | pub trait Vcs: Send + Sync { |
22 | | /// Given any path (possibly inside a worktree/checkout), resolve to the |
23 | | /// main repository root. Returns None if the path is not inside a repo. |
24 | | async fn resolve_repo_root(&self, path: &ExecutionEnvironmentPath) -> Option<ExecutionEnvironmentPath>; |
25 | | async fn list_local_branches(&self, repo_root: &ExecutionEnvironmentPath) -> Result<Vec<BranchInfo>, String>; |
26 | | async fn list_remote_branches(&self, repo_root: &ExecutionEnvironmentPath) -> Result<Vec<String>, String>; |
27 | | async fn commit_log(&self, repo_root: &ExecutionEnvironmentPath, branch: &str, limit: usize) -> Result<Vec<CommitInfo>, String>; |
28 | | async fn ahead_behind(&self, repo_root: &ExecutionEnvironmentPath, branch: &str, reference: &str) -> Result<AheadBehind, String>; |
29 | | async fn working_tree_status( |
30 | | &self, |
31 | | repo_root: &ExecutionEnvironmentPath, |
32 | | checkout_path: &ExecutionEnvironmentPath, |
33 | | ) -> Result<WorkingTreeStatus, String>; |
34 | | } |
35 | | |
36 | | #[async_trait] |
37 | | pub trait CheckoutManager: Send + Sync { |
38 | | /// Validate whether this checkout manager can satisfy the requested branch intent. |
39 | | /// |
40 | | /// For ambient checkout flows the executor calls this before `create_checkout`. |
41 | | /// Managers used in constructed environments may need to call it from |
42 | | /// `create_checkout` themselves when bootstrap/discovery bypasses that outer preflight. |
43 | | async fn validate_target(&self, repo_root: &ExecutionEnvironmentPath, branch: &str, intent: CheckoutIntent) -> Result<(), String>; |
44 | | async fn list_checkouts(&self, repo_root: &ExecutionEnvironmentPath) -> Result<Vec<(ExecutionEnvironmentPath, Checkout)>, String>; |
45 | | async fn create_checkout( |
46 | | &self, |
47 | | repo_root: &ExecutionEnvironmentPath, |
48 | | branch: &str, |
49 | | create_branch: bool, |
50 | | ) -> Result<(ExecutionEnvironmentPath, Checkout), String>; |
51 | | async fn remove_checkout(&self, repo_root: &ExecutionEnvironmentPath, branch: &str) -> Result<(), String>; |
52 | | } |
53 | | |
54 | | #[allow(dead_code)] |
55 | | pub struct VcsBundle { |
56 | | pub vcs: Box<dyn Vcs>, |
57 | | pub checkout_manager: Box<dyn CheckoutManager>, |
58 | | } |
59 | | |
60 | | /// Parse `git status --porcelain` output into a `WorkingTreeStatus`. |
61 | | /// |
62 | | /// Each line has a two-character status prefix: X Y, where X is the index |
63 | | /// (staging area) status and Y is the working-tree status. `??` means |
64 | | /// untracked. This is the single canonical implementation used by both |
65 | | /// the `Vcs` and `CheckoutManager` providers. |
66 | 26 | pub(crate) fn parse_porcelain_status(output: &str) -> WorkingTreeStatus { |
67 | 26 | let mut staged = 0usize; |
68 | 26 | let mut modified = 0usize; |
69 | 26 | let mut untracked = 0usize; |
70 | 26 | for line7 in output.lines() { |
71 | 7 | let bytes = line.as_bytes(); |
72 | 7 | if bytes.len() < 2 { |
73 | 0 | continue; |
74 | 7 | } |
75 | 7 | let x = bytes[0]; |
76 | 7 | let y = bytes[1]; |
77 | 7 | if x == b'?' { |
78 | 2 | untracked += 1; |
79 | 2 | } else { |
80 | 5 | if x != b' ' { |
81 | 3 | staged += 1; |
82 | 3 | }2 |
83 | 5 | if y != b' ' && y != b'?'2 { |
84 | 2 | modified += 1; |
85 | 3 | } |
86 | | } |
87 | | } |
88 | 26 | WorkingTreeStatus { staged, modified, untracked } |
89 | 26 | } |
90 | | |
91 | | /// Parse the output of `git rev-list --count --left-right` into an `AheadBehind`. |
92 | | /// |
93 | | /// Output format is `<ahead>\t<behind>\n`. |
94 | 17 | pub(crate) fn parse_ahead_behind(output: &str) -> Option<AheadBehind> { |
95 | 17 | let trimmed = output.trim(); |
96 | 17 | let mut parts = trimmed.split('\t'); |
97 | 17 | let ahead15 : i6415 = parts.next()?0 .parse().ok()?2 ; |
98 | 15 | let behind: i64 = parts.next()?0 .parse().ok()?0 ; |
99 | 15 | Some(AheadBehind { ahead, behind }) |
100 | 17 | } |
101 | | |
102 | | /// Parse the output of `git config --get-regexp 'branch\.<branch>\.flotilla\.issues\.'` |
103 | | /// into association keys. Each line has the format: |
104 | | /// `branch.<name>.flotilla.issues.<provider> id1,id2,...` |
105 | 3 | pub fn parse_issue_config_output(output: &str) -> Vec<AssociationKey> { |
106 | 3 | let mut keys = Vec::new(); |
107 | 3 | for line in output.lines() { |
108 | 3 | let line = line.trim(); |
109 | 3 | if line.is_empty() { |
110 | 0 | continue; |
111 | 3 | } |
112 | 3 | let Some((config_key, value)) = line.split_once(' ') else { |
113 | 0 | continue; |
114 | | }; |
115 | 3 | let Some(provider) = config_key.rsplit_once(".issues.").map(|(_, p)| p) else { |
116 | 0 | continue; |
117 | | }; |
118 | 4 | for id in value3 .split3 (',') { |
119 | 4 | let id = id.trim(); |
120 | 4 | if !id.is_empty() { |
121 | 4 | keys.push(AssociationKey::IssueRef(provider.to_string(), id.to_string())); |
122 | 4 | }0 |
123 | | } |
124 | | } |
125 | 3 | keys |
126 | 3 | } |
127 | | |
128 | | /// Read issue links from git config for a specific branch. |
129 | | /// Returns empty vec if no links or on error (non-fatal). |
130 | 25 | pub(crate) async fn read_branch_issue_links(repo_root: &Path, branch: &str, runner: &dyn CommandRunner) -> Vec<AssociationKey> { |
131 | 25 | let pattern = format!("branch\\.{}\\.flotilla\\.issues\\.", regex_escape_branch(branch)); |
132 | 25 | let result = run!(runner, "git", &["config", "--get-regexp", &pattern], repo_root); |
133 | 25 | match result { |
134 | 0 | Ok(output) => parse_issue_config_output(&output), |
135 | 25 | Err(_) => Vec::new(), |
136 | | } |
137 | 25 | } |
138 | | |
139 | | /// Write issue links to git config for a specific branch. |
140 | | /// Errors are logged and ignored because issue linking is best-effort metadata. |
141 | 9 | pub(crate) async fn write_branch_issue_links(repo_root: &Path, branch: &str, issue_ids: &[(String, String)], runner: &dyn CommandRunner) { |
142 | | use std::collections::HashMap; |
143 | | |
144 | 9 | let mut by_provider: HashMap<&str, Vec<&str>> = HashMap::new(); |
145 | 11 | for (provider, id) in issue_ids9 { |
146 | 11 | by_provider.entry(provider.as_str()).or_default().push(id.as_str()); |
147 | 11 | } |
148 | 9 | for (provider, ids) in by_provider { |
149 | 9 | let key = format!("branch.{branch}.flotilla.issues.{provider}"); |
150 | 9 | let value = ids.join(","); |
151 | 9 | if let Err(err3 ) = run!(runner, "git", &["config", &key, &value], repo_root) { |
152 | 3 | warn!(err = %err, "failed to write issue link"); |
153 | 6 | } |
154 | | } |
155 | 9 | } |
156 | | |
157 | 14 | async fn validate_checkout_target_with_prefix( |
158 | 14 | cwd: &Path, |
159 | 14 | prefix: &[&str], |
160 | 14 | branch: &str, |
161 | 14 | intent: CheckoutIntent, |
162 | 14 | runner: &dyn CommandRunner, |
163 | 14 | ) -> Result<(), String> { |
164 | 14 | let local_ref = format!("refs/heads/{branch}"); |
165 | 14 | let remote_ref = format!("refs/remotes/origin/{branch}"); |
166 | | |
167 | 14 | let mut local_args = prefix.to_vec(); |
168 | 14 | local_args.extend(["show-ref", "--verify", "--quiet", local_ref.as_str()]); |
169 | 14 | let local_exists = runner.run("git", &local_args, cwd, &ChannelLabel::Noop).await.is_ok(); |
170 | | |
171 | 14 | let mut remote_args = prefix.to_vec(); |
172 | 14 | remote_args.extend(["show-ref", "--verify", "--quiet", remote_ref.as_str()]); |
173 | 14 | let remote_exists = runner.run("git", &remote_args, cwd, &ChannelLabel::Noop).await.is_ok(); |
174 | | |
175 | 4 | match intent { |
176 | 4 | CheckoutIntent::ExistingBranch if local_exists || remote_exists2 => Ok(())2 , |
177 | 2 | CheckoutIntent::ExistingBranch => Err(format!("branch not found: {branch}")), |
178 | 10 | CheckoutIntent::FreshBranch if local_exists || remote_exists3 => Err(format!("branch already exists: {branch}"))3 , |
179 | 7 | CheckoutIntent::FreshBranch => Ok(()), |
180 | | } |
181 | 14 | } |
182 | | |
183 | 11 | pub(crate) async fn validate_checkout_target_in_repo( |
184 | 11 | repo_root: &Path, |
185 | 11 | branch: &str, |
186 | 11 | intent: CheckoutIntent, |
187 | 11 | runner: &dyn CommandRunner, |
188 | 11 | ) -> Result<(), String> { |
189 | 11 | validate_checkout_target_with_prefix(repo_root, &[], branch, intent, runner).await |
190 | 11 | } |
191 | | |
192 | 3 | pub(crate) async fn validate_checkout_target_in_git_dir( |
193 | 3 | git_dir: &Path, |
194 | 3 | cwd: &Path, |
195 | 3 | branch: &str, |
196 | 3 | intent: CheckoutIntent, |
197 | 3 | runner: &dyn CommandRunner, |
198 | 3 | ) -> Result<(), String> { |
199 | 3 | let git_dir = git_dir.to_str().ok_or_else(|| "git dir path is not valid UTF-8"0 .to_string0 ())?0 ; |
200 | 3 | validate_checkout_target_with_prefix(cwd, &["--git-dir", git_dir], branch, intent, runner).await |
201 | 3 | } |
202 | | |
203 | | /// Escape special regex characters in branch names for git config --get-regexp. |
204 | 27 | fn regex_escape_branch(branch: &str) -> String { |
205 | 27 | let mut escaped = String::with_capacity(branch.len()); |
206 | 176 | for c in branch27 .chars27 () { |
207 | 176 | match c { |
208 | 1 | '.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '\\' | '|' | '^' | '$' => { |
209 | 1 | escaped.push('\\'); |
210 | 1 | escaped.push(c); |
211 | 1 | } |
212 | 175 | _ => escaped.push(c), |
213 | | } |
214 | | } |
215 | 27 | escaped |
216 | 27 | } |
217 | | |
218 | | #[cfg(test)] |
219 | | mod tests { |
220 | | use flotilla_protocol::CheckoutIntent; |
221 | | |
222 | | use super::*; |
223 | | use crate::providers::testing::MockRunner; |
224 | | |
225 | | #[test] |
226 | 1 | fn parse_issue_links_single_provider() { |
227 | 1 | let git_output = "branch.feat-x.flotilla.issues.github 123,456\n"; |
228 | 1 | let keys = parse_issue_config_output(git_output); |
229 | 1 | assert_eq!(keys, vec![ |
230 | 1 | AssociationKey::IssueRef("github".into(), "123".into()), |
231 | 1 | AssociationKey::IssueRef("github".into(), "456".into()), |
232 | | ]); |
233 | 1 | } |
234 | | |
235 | | #[test] |
236 | 1 | fn parse_issue_links_multiple_providers() { |
237 | 1 | let git_output = "branch.feat-x.flotilla.issues.github 42\nbranch.feat-x.flotilla.issues.linear ABC-123\n"; |
238 | 1 | let keys = parse_issue_config_output(git_output); |
239 | 1 | assert_eq!(keys, vec![ |
240 | 1 | AssociationKey::IssueRef("github".into(), "42".into()), |
241 | 1 | AssociationKey::IssueRef("linear".into(), "ABC-123".into()), |
242 | | ]); |
243 | 1 | } |
244 | | |
245 | | #[test] |
246 | 1 | fn parse_issue_links_empty() { |
247 | 1 | let keys = parse_issue_config_output(""); |
248 | 1 | assert!(keys.is_empty()); |
249 | 1 | } |
250 | | |
251 | | #[test] |
252 | 1 | fn regex_escape_branch_with_dots() { |
253 | 1 | assert_eq!(regex_escape_branch("feat.x"), "feat\\.x"); |
254 | 1 | assert_eq!(regex_escape_branch("simple"), "simple"); |
255 | 1 | } |
256 | | |
257 | | #[test] |
258 | 1 | fn parse_ahead_behind_normal() { |
259 | 1 | let ab = parse_ahead_behind("3\t5\n").expect("should parse"); |
260 | 1 | assert_eq!(ab.ahead, 3); |
261 | 1 | assert_eq!(ab.behind, 5); |
262 | 1 | } |
263 | | |
264 | | #[test] |
265 | 1 | fn parse_ahead_behind_zeros() { |
266 | 1 | let ab = parse_ahead_behind("0\t0\n").expect("should parse"); |
267 | 1 | assert_eq!(ab.ahead, 0); |
268 | 1 | assert_eq!(ab.behind, 0); |
269 | 1 | } |
270 | | |
271 | | #[test] |
272 | 1 | fn parse_ahead_behind_empty() { |
273 | 1 | assert!(parse_ahead_behind("").is_none()); |
274 | 1 | } |
275 | | |
276 | | #[test] |
277 | 1 | fn parse_ahead_behind_malformed() { |
278 | 1 | assert!(parse_ahead_behind("notanumber\t5").is_none()); |
279 | 1 | } |
280 | | |
281 | | #[tokio::test] |
282 | 1 | async fn write_branch_issue_links_single_provider_multiple_issues() { |
283 | 1 | let runner = MockRunner::new(vec![Ok(String::new())]); |
284 | 1 | let issue_ids = vec![("github".to_string(), "10".to_string()), ("github".to_string(), "20".to_string())]; |
285 | | |
286 | 1 | write_branch_issue_links(Path::new("/repo"), "feat-x", &issue_ids, &runner).await; |
287 | | |
288 | 1 | assert_eq!(runner.remaining(), 0, "single provider should consume exactly 1 response"); |
289 | 1 | assert_eq!(runner.calls(), vec![("git".to_string(), vec![ |
290 | 1 | "config".to_string(), |
291 | 1 | "branch.feat-x.flotilla.issues.github".to_string(), |
292 | 1 | "10,20".to_string() |
293 | 1 | ],)]); |
294 | 1 | } |
295 | | |
296 | | #[tokio::test] |
297 | 1 | async fn write_branch_issue_links_multiple_providers() { |
298 | 1 | let runner = MockRunner::new(vec![Ok(String::new()), Ok(String::new())]); |
299 | 1 | let issue_ids = vec![("github".to_string(), "10".to_string()), ("jira".to_string(), "PROJ-5".to_string())]; |
300 | | |
301 | 1 | write_branch_issue_links(Path::new("/repo"), "feat-x", &issue_ids, &runner).await; |
302 | | |
303 | 1 | assert_eq!(runner.remaining(), 0, "two providers should consume exactly 2 responses"); |
304 | 1 | let calls = runner.calls(); |
305 | 1 | assert_eq!(calls.len(), 2); |
306 | 1 | } |
307 | | |
308 | | #[tokio::test] |
309 | 1 | async fn write_branch_issue_links_git_error_tolerated() { |
310 | 1 | let runner = MockRunner::new(vec![Err("git config failed".to_string())]); |
311 | 1 | let issue_ids = vec![("github".to_string(), "10".to_string())]; |
312 | | |
313 | 1 | write_branch_issue_links(Path::new("/repo"), "feat-x", &issue_ids, &runner).await; |
314 | | |
315 | 1 | assert_eq!(runner.remaining(), 0, "should still consume the response even on error"); |
316 | 1 | } |
317 | | |
318 | | #[tokio::test] |
319 | 1 | async fn write_branch_issue_links_empty_is_noop() { |
320 | 1 | let runner = MockRunner::new(vec![]); |
321 | | |
322 | 1 | write_branch_issue_links(Path::new("/repo"), "feat-x", &[], &runner).await; |
323 | | |
324 | 1 | assert_eq!(runner.remaining(), 0, "empty issue_ids should make zero calls"); |
325 | 1 | } |
326 | | |
327 | | #[tokio::test] |
328 | 1 | async fn validate_fresh_branch_succeeds_when_neither_exists() { |
329 | 1 | let runner = MockRunner::new(vec![Err("not found".to_string()), Err("not found".to_string())]); |
330 | | |
331 | 1 | let result = validate_checkout_target_in_repo(Path::new("/repo"), "new-branch", CheckoutIntent::FreshBranch, &runner).await; |
332 | | |
333 | 1 | assert!(result.is_ok()); |
334 | 1 | } |
335 | | |
336 | | #[tokio::test] |
337 | 1 | async fn validate_fresh_branch_fails_when_local_exists() { |
338 | 1 | let runner = MockRunner::new(vec![Ok(String::new()), Err("not found".to_string())]); |
339 | | |
340 | 1 | let result = validate_checkout_target_in_repo(Path::new("/repo"), "existing", CheckoutIntent::FreshBranch, &runner).await; |
341 | | |
342 | 1 | assert!(result.is_err()); |
343 | 1 | assert!(result.unwrap_err().contains("already exists")); |
344 | 1 | } |
345 | | |
346 | | #[tokio::test] |
347 | 1 | async fn validate_fresh_branch_fails_when_remote_exists() { |
348 | 1 | let runner = MockRunner::new(vec![Err("not found".to_string()), Ok(String::new())]); |
349 | | |
350 | 1 | let result = validate_checkout_target_in_repo(Path::new("/repo"), "remote-only", CheckoutIntent::FreshBranch, &runner).await; |
351 | | |
352 | 1 | assert!(result.is_err()); |
353 | 1 | assert!(result.unwrap_err().contains("already exists")); |
354 | 1 | } |
355 | | |
356 | | #[tokio::test] |
357 | 1 | async fn validate_existing_branch_succeeds_when_local_exists() { |
358 | 1 | let runner = MockRunner::new(vec![Ok(String::new()), Err("not found".to_string())]); |
359 | | |
360 | 1 | let result = validate_checkout_target_in_repo(Path::new("/repo"), "local-branch", CheckoutIntent::ExistingBranch, &runner).await; |
361 | | |
362 | 1 | assert!(result.is_ok()); |
363 | 1 | } |
364 | | |
365 | | #[tokio::test] |
366 | 1 | async fn validate_existing_branch_succeeds_when_remote_exists() { |
367 | 1 | let runner = MockRunner::new(vec![Err("not found".to_string()), Ok(String::new())]); |
368 | | |
369 | 1 | let result = validate_checkout_target_in_repo(Path::new("/repo"), "remote-branch", CheckoutIntent::ExistingBranch, &runner).await; |
370 | | |
371 | 1 | assert!(result.is_ok()); |
372 | 1 | } |
373 | | |
374 | | #[tokio::test] |
375 | 1 | async fn validate_existing_branch_fails_when_neither_exists() { |
376 | 1 | let runner = MockRunner::new(vec![Err("not found".to_string()), Err("not found".to_string())]); |
377 | | |
378 | 1 | let result = validate_checkout_target_in_repo(Path::new("/repo"), "ghost-branch", CheckoutIntent::ExistingBranch, &runner).await; |
379 | | |
380 | 1 | assert!(result.is_err()); |
381 | 1 | assert!(result.unwrap_err().contains("branch not found")); |
382 | 1 | } |
383 | | } |
384 | | |
385 | | /// Shared test utilities for checkout manager implementations. |
386 | | #[cfg(test)] |
387 | | pub(crate) mod checkout_test_support { |
388 | | use std::{ |
389 | | path::{Path, PathBuf}, |
390 | | sync::Arc, |
391 | | }; |
392 | | |
393 | | use crate::{ |
394 | | path_context::ExecutionEnvironmentPath, |
395 | | providers::{vcs::CheckoutManager, ChannelLabel, CommandRunner}, |
396 | | }; |
397 | | |
398 | | /// Run a git command, panicking on failure. |
399 | 8 | pub fn git(cwd: &Path, args: &[&str]) { |
400 | 8 | let out = std::process::Command::new("git") |
401 | 8 | .args(args) |
402 | 8 | .current_dir(cwd) |
403 | 8 | .stdin(std::process::Stdio::null()) |
404 | 8 | .output() |
405 | 8 | .expect("failed to spawn git"); |
406 | 8 | assert!(out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy0 (&out.stderr0 )); |
407 | 8 | } |
408 | | |
409 | | /// Create a repo where `feature/remote-only` exists on the remote but not locally. |
410 | | /// The remote branch has a commit "remote-only work" ahead of main. |
411 | 0 | pub fn setup_remote_only_branch() -> (tempfile::TempDir, PathBuf) { |
412 | 0 | let dir = tempfile::tempdir().expect("failed to create tempdir"); |
413 | 0 | let base = dir.path().canonicalize().expect("failed to canonicalize tempdir"); |
414 | 0 | let remote = base.join("remote.git"); |
415 | 0 | let repo = base.join("repo"); |
416 | | |
417 | 0 | git(&base, &["init", "--bare", remote.to_str().expect("non-UTF-8 path")]); |
418 | 0 | git(&base, &["clone", remote.to_str().expect("non-UTF-8 path"), repo.to_str().expect("non-UTF-8 path")]); |
419 | 0 | git(&repo, &["config", "user.email", "test@test.com"]); |
420 | 0 | git(&repo, &["config", "user.name", "Test"]); |
421 | | |
422 | | // Initial commit on main |
423 | 0 | std::fs::write(repo.join("README.md"), "# Test\n").expect("failed to write README"); |
424 | 0 | git(&repo, &["add", "README.md"]); |
425 | 0 | git(&repo, &["commit", "-m", "Initial commit"]); |
426 | 0 | git(&repo, &["push", "origin", "main"]); |
427 | | |
428 | | // Create feature branch, commit, push, then delete local |
429 | 0 | git(&repo, &["checkout", "-b", "feature/remote-only"]); |
430 | 0 | std::fs::write(repo.join("remote-work.txt"), "work\n").expect("failed to write test file"); |
431 | 0 | git(&repo, &["add", "remote-work.txt"]); |
432 | 0 | git(&repo, &["commit", "-m", "remote-only work"]); |
433 | 0 | git(&repo, &["push", "origin", "feature/remote-only"]); |
434 | | |
435 | | // Back to main, delete local branch |
436 | 0 | git(&repo, &["checkout", "main"]); |
437 | 0 | git(&repo, &["branch", "-D", "feature/remote-only"]); |
438 | | |
439 | 0 | (dir, repo) |
440 | 0 | } |
441 | | |
442 | | /// Assert that create_checkout correctly tracks a remote-only branch. |
443 | | /// |
444 | | /// The worktree should end up on the remote branch's commit ("remote-only work"), |
445 | | /// not on main's HEAD ("Initial commit"). |
446 | 2 | pub async fn assert_checkout_tracks_remote_branch( |
447 | 2 | mgr: &dyn CheckoutManager, |
448 | 2 | runner: &Arc<dyn CommandRunner>, |
449 | 2 | repo_path: &ExecutionEnvironmentPath, |
450 | 2 | ) { |
451 | 2 | let (wt_path, checkout) = |
452 | 2 | mgr.create_checkout(repo_path, "feature/remote-only", true).await.expect("create_checkout should succeed"); |
453 | | |
454 | 2 | assert_eq!(checkout.branch, "feature/remote-only"); |
455 | 2 | assert!(!checkout.is_main); |
456 | | |
457 | 2 | let commit = checkout.last_commit.as_ref().expect("should have commit info"); |
458 | 2 | assert_eq!(commit.message, "remote-only work", "checkout should be on the remote branch's commit, not main"); |
459 | | |
460 | | // Verify via direct git command through the runner |
461 | 2 | let label = ChannelLabel::Command("verify-commit".into()); |
462 | 2 | let log_output = runner.run("git", &["log", "-1", "--format=%s"], wt_path.as_path(), &label).await.expect("git log should succeed"); |
463 | 2 | assert_eq!(log_output.trim(), "remote-only work", "worktree HEAD should be the remote branch's tip"); |
464 | 2 | } |
465 | | } |