Coverage Report

Created: 2026-04-05 07:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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_exist
s2
=>
Ok(())2
,
177
2
        CheckoutIntent::ExistingBranch => Err(format!("branch not found: {branch}")),
178
10
        CheckoutIntent::FreshBranch if local_exists || remote_exist
s3
=>
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
}