diff --git a/docs/spec/worktree-monitor.md b/docs/spec/worktree-monitor.md index 3b2615d..a7e7770 100644 --- a/docs/spec/worktree-monitor.md +++ b/docs/spec/worktree-monitor.md @@ -180,10 +180,19 @@ Intervals adapt to user activity level (Active / Idle / Deep Idle). See `docs/sp For fork workflows (push to fork, PRs in upstream repo), treemon auto-detects and uses the correct remote: - **Resolution order**: `.treemon.json` `"upstreamRemote"` field → auto-detect `upstream` remote → fall back to `origin` -- **Affects**: PR fetching (remote URL), main branch comparisons (`{remote}/main`), fetch cycle, sync merge target +- **Affects**: PR fetching (remote URL), base branch comparisons (`{remote}/{baseBranch}`), fetch cycle, sync merge target - **Stored** per-repo in `PerRepoState.UpstreamRemote`, resolved during worktree list refresh - **Config example**: `{ "upstreamRemote": "upstream" }` in `.treemon.json` at repo root +### Base Branch Resolution + +Each repo can configure which branch is considered the "base" for ahead/behind counts, diff stats, fetch, fast-forward, and sync operations: + +- **Resolution**: `.treemon.json` `"baseBranch"` field → default `"main"` +- **Affects**: `git rev-list` behind/commit counts, `git diff --shortstat`, `git fetch`, fast-forward, merge/rebase targets, branch sort priority +- **Stored** per-repo in `PerRepoState.BaseBranch`, resolved during worktree list refresh +- **Config example**: `{ "baseBranch": "dev" }` in `.treemon.json` at repo root + ### CommentSummary - `WithResolution of unresolved * total` — thread resolution tracking (both AzDo and GitHub) diff --git a/src/Client/App.fs b/src/Client/App.fs index caf69fc..34fa676 100644 --- a/src/Client/App.fs +++ b/src/Client/App.fs @@ -209,7 +209,8 @@ let update msg model = IsCollapsed = if isFirstLoad then response.CollapsedRepos |> Set.contains r.RepoId else existingCollapse |> Map.tryFind r.RepoId |> Option.defaultValue false - Provider = r.Provider }) + Provider = r.Provider + BaseBranch = r.BaseBranch }) |> filterDeletedPaths stillPending { model with Repos = repos @@ -1458,6 +1459,8 @@ let repoSectionHeader dispatch (focusedElement: FocusTarget option) (repo: RepoM Html.span [ prop.className "collapse-arrow"; prop.text arrow ] Html.span [ prop.className "repo-name"; prop.text repo.Name ] providerIcon repo.Provider + if repo.BaseBranch <> "main" then + Html.span [ prop.className "deploy-branch"; prop.text repo.BaseBranch ] if repo.IsCollapsed then Html.span [ prop.className "repo-ct-dots" diff --git a/src/Client/Navigation.fs b/src/Client/Navigation.fs index 207bede..4529c6f 100644 --- a/src/Client/Navigation.fs +++ b/src/Client/Navigation.fs @@ -15,7 +15,8 @@ type RepoModel = ArchivedWorktrees: WorktreeStatus list IsReady: bool IsCollapsed: bool - Provider: RepoProvider option } + Provider: RepoProvider option + BaseBranch: string } type NavAction = | NoAction diff --git a/src/Server/DemoFixture.fs b/src/Server/DemoFixture.fs index 7c010ef..b2dca6d 100644 --- a/src/Server/DemoFixture.fs +++ b/src/Server/DemoFixture.fs @@ -330,12 +330,14 @@ let private baseDashboard: DashboardResponse = RootFolderName = "CloudPlatform" Worktrees = [ wtAzDoMain; wtRetryLogic; wtConfigLoading; wtAuthMiddleware; wtArchived ] IsReady = true - Provider = Some(AzDoProvider "https://dev.azure.com/contoso/CloudPlatform") } + Provider = Some(AzDoProvider "https://dev.azure.com/contoso/CloudPlatform") + BaseBranch = "main" } { RepoId = githubRepoId RootFolderName = "DataPipeline" Worktrees = [ wtGithubMain; wtStreaming; wtCsvFix ] IsReady = true - Provider = Some(GitHubProvider "https://github.com/contoso/DataPipeline") } ] + Provider = Some(GitHubProvider "https://github.com/contoso/DataPipeline") + BaseBranch = "main" } ] SchedulerEvents = baseSchedulerEvents LatestByCategory = baseLatestByCategory AppVersion = "demo|0" diff --git a/src/Server/GitWorktree.fs b/src/Server/GitWorktree.fs index 84caaaa..4a3f8d4 100644 --- a/src/Server/GitWorktree.fs +++ b/src/Server/GitWorktree.fs @@ -106,26 +106,26 @@ let getLastCommit (worktreePath: string) = return parseCommitOutput worktreePath fallback } -let private tryFastForwardMain (repoRoot: string) (mainRef: string) = +let private tryFastForwardMain (repoRoot: string) (baseBranch: string) (mainRef: string) = async { let! currentBranch = runGit repoRoot "rev-parse --abbrev-ref HEAD" match currentBranch |> Option.map _.Trim() with - | Some "main" -> + | Some branch when branch = baseBranch -> let! result = runGitResult repoRoot $"merge --ff-only {mainRef}" match result with - | Ok _ -> Log.log "Git" $"Fast-forwarded main in {repoRoot}" + | Ok _ -> Log.log "Git" $"Fast-forwarded {baseBranch} in {repoRoot}" | Error msg -> Log.log "Git" $"Fast-forward skipped in {repoRoot}: {msg}" | _ -> () } -let mainRef (upstreamRemote: string) = $"{upstreamRemote}/main" +let mainRef (upstreamRemote: string) (baseBranch: string) = $"{upstreamRemote}/{baseBranch}" -let fetchUpstream (repoRoot: string) (upstreamRemote: string) = +let fetchUpstream (repoRoot: string) (upstreamRemote: string) (baseBranch: string) = async { - let! _ = runGit repoRoot $"fetch {upstreamRemote} main" - do! tryFastForwardMain repoRoot (mainRef upstreamRemote) + let! _ = runGit repoRoot $"fetch {upstreamRemote} {baseBranch}" + do! tryFastForwardMain repoRoot baseBranch (mainRef upstreamRemote baseBranch) } let getMainBehindCount (worktreePath: string) (mainRef: string) = @@ -320,9 +320,10 @@ let removeWorktree (repoRoot: string) (worktreePath: string) (branch: string opt |> AsyncResult.ignore } -let branchSortKey (name: string) = +let branchSortKey (baseBranch: string) (name: string) = match name with - | "main" -> (0, name) + | n when n = baseBranch -> (0, name) + | "main" -> (1, name) | "master" -> (1, name) | "develop" -> (2, name) | n when n.StartsWith("dev") -> (3, name) diff --git a/src/Server/RefreshScheduler.fs b/src/Server/RefreshScheduler.fs index dc50567..d8c1e0e 100644 --- a/src/Server/RefreshScheduler.fs +++ b/src/Server/RefreshScheduler.fs @@ -16,6 +16,7 @@ type PerRepoState = PrData: Map Provider: RepoProvider option UpstreamRemote: string + BaseBranch: string IsReady: bool } module PerRepoState = @@ -28,6 +29,7 @@ module PerRepoState = PrData = Map.empty Provider = None UpstreamRemote = "origin" + BaseBranch = "main" IsReady = false } type DashboardState = @@ -57,6 +59,7 @@ type StateMsg = | UpdatePr of repoId: RepoId * Map | UpdateProvider of repoId: RepoId * RepoProvider option | UpdateUpstreamRemote of repoId: RepoId * remote: string + | UpdateBaseBranch of repoId: RepoId * baseBranch: string | RemoveWorktree of repoId: RepoId * path: string | GetState of AsyncReplyChannel | LogSchedulerEvent of CardEvent @@ -145,6 +148,10 @@ let private processMessage (state: DashboardState) (msg: StateMsg) = let repo = getRepo repoId state updateRepo repoId { repo with UpstreamRemote = remote } state + | UpdateBaseBranch(repoId, baseBranch) -> + let repo = getRepo repoId state + updateRepo repoId { repo with BaseBranch = baseBranch } state + | RemoveWorktree(repoId, path) -> let repo = getRepo repoId state updateRepo repoId (removeWorktreeData path repo) state @@ -341,6 +348,8 @@ let private executeTask let! upstreamRemote = GitWorktree.resolveUpstreamRemote root agent.Post(UpdateWorktreeList(repoId, worktrees)) agent.Post(UpdateUpstreamRemote(repoId, upstreamRemote)) + let baseBranch = TreemonConfig.readBaseBranch root + agent.Post(UpdateBaseBranch(repoId, baseBranch)) let! state = agent.PostAndAsyncReply(GetState) let alreadyDetected = state.Repos |> Map.tryFind repoId |> Option.bind _.Provider |> Option.isSome if not alreadyDetected then @@ -351,7 +360,7 @@ let private executeTask | RefreshGit(repoId, path) -> let! state = agent.PostAndAsyncReply(GetState) let repo = state.Repos |> Map.tryFind repoId |> Option.defaultValue PerRepoState.empty - let mainRef = GitWorktree.mainRef repo.UpstreamRemote + let mainRef = GitWorktree.mainRef repo.UpstreamRemote repo.BaseBranch let branch = repo.WorktreeList @@ -387,7 +396,7 @@ let private executeTask let root = rootPaths |> Map.find repoId let! state = agent.PostAndAsyncReply(GetState) let repo = state.Repos |> Map.tryFind repoId |> Option.defaultValue PerRepoState.empty - do! GitWorktree.fetchUpstream root repo.UpstreamRemote + do! GitWorktree.fetchUpstream root repo.UpstreamRemote repo.BaseBranch } let private timeoutMs = 60_000 diff --git a/src/Server/SyncEngine.fs b/src/Server/SyncEngine.fs index 6ba7385..bea62fd 100644 --- a/src/Server/SyncEngine.fs +++ b/src/Server/SyncEngine.fs @@ -291,8 +291,8 @@ module private PipelineSteps = let fetch (ctx: StepContext) (upstreamRemote: string) = runStep ctx SyncStep.Pull "git" (buildFetchArgs upstreamRemote) checkExitCode - let merge (ctx: StepContext) (upstreamRemote: string) : Async> = - let mergeTarget = GitWorktree.mainRef upstreamRemote + let merge (ctx: StepContext) (upstreamRemote: string) (baseBranch: string) : Async> = + let mergeTarget = GitWorktree.mainRef upstreamRemote baseBranch let cmdString = $"git merge {mergeTarget}" async { ctx.Post (UpdateProcessState (ctx.Branch, SyncState.Running SyncStep.Merge)) @@ -351,24 +351,24 @@ module private PipelineSteps = let push (ctx: StepContext) = runStep ctx SyncStep.Push "git" "push" checkExitCode - let rebase (ctx: StepContext) (upstreamRemote: string) = - let mergeTarget = GitWorktree.mainRef upstreamRemote + let rebase (ctx: StepContext) (upstreamRemote: string) (baseBranch: string) = + let mergeTarget = GitWorktree.mainRef upstreamRemote baseBranch runStep ctx SyncStep.Rebase "git" $"rebase {mergeTarget}" checkExitCode -let executeSyncPipeline (post: SyncMsg -> unit) (branch: string) (worktreePath: string) (repoRoot: string) (provider: Shared.CodingToolProvider option) (upstreamRemote: string) (prStatus: PrStatus) (ct: CancellationToken) : Async = +let executeSyncPipeline (post: SyncMsg -> unit) (branch: string) (worktreePath: string) (repoRoot: string) (provider: Shared.CodingToolProvider option) (upstreamRemote: string) (baseBranch: string) (prStatus: PrStatus) (ct: CancellationToken) : Async = let ctx = { Post = post; Branch = branch; WorktreePath = worktreePath; Ct = ct } let pipeline () = asyncResult { do! PipelineSteps.checkClean ctx do! PipelineSteps.fetch ctx upstreamRemote - let mainRef = GitWorktree.mainRef upstreamRemote + let mainRef = GitWorktree.mainRef upstreamRemote baseBranch let! commitCount = GitWorktree.getCommitCount ctx.WorktreePath mainRef |> Async.map Ok if commitCount = 0 then - do! PipelineSteps.rebase ctx upstreamRemote + do! PipelineSteps.rebase ctx upstreamRemote baseBranch else - let! hasConflicts = PipelineSteps.merge ctx upstreamRemote + let! hasConflicts = PipelineSteps.merge ctx upstreamRemote baseBranch if hasConflicts then do! PipelineSteps.resolveConflicts ctx provider do! PipelineSteps.runTests ctx repoRoot diff --git a/src/Server/TreemonConfig.fs b/src/Server/TreemonConfig.fs index 3edc640..e77fa69 100644 --- a/src/Server/TreemonConfig.fs +++ b/src/Server/TreemonConfig.fs @@ -11,6 +11,7 @@ let private configLock = obj () let private configPath repoRoot = Path.Combine(repoRoot, ".treemon.json") let private validRemoteNamePattern = Regex(@"^[a-zA-Z0-9._-]+$") +let private validBranchNamePattern = Regex(@"^[a-zA-Z0-9][a-zA-Z0-9._/-]*$") let private withJsonProperty (path: string) (propertyName: string) (onFound: JsonElement -> 'a) (defaultValue: 'a) : 'a = if not (File.Exists(path)) then @@ -90,6 +91,15 @@ let readUpstreamRemote (repoRoot: string) : string option = Log.log "TreemonConfig" $"Rejected invalid upstreamRemote value: '{value}'" None) +let readBaseBranch (repoRoot: string) : string = + readStringConfig repoRoot "baseBranch" + |> Option.bind (fun value -> + if validBranchNamePattern.IsMatch(value) then Some value + else + Log.log "TreemonConfig" $"Rejected invalid baseBranch value: '{value}'" + None) + |> Option.defaultValue "main" + let readTestCommand (repoRoot: string) : string option = readStringConfig repoRoot "testCommand" diff --git a/src/Server/WorktreeApi.fs b/src/Server/WorktreeApi.fs index 7dc96f4..37564e4 100644 --- a/src/Server/WorktreeApi.fs +++ b/src/Server/WorktreeApi.fs @@ -227,7 +227,8 @@ let getWorktrees RootFolderName = Path.GetFileName(originalPath) Worktrees = statuses IsReady = repo.IsReady - Provider = repo.Provider }) + Provider = repo.Provider + BaseBranch = repo.BaseBranch }) return { Repos = repos @@ -399,7 +400,7 @@ let worktreeApi let repo = state.Repos |> Map.tryFind ctx.RepoId |> Option.defaultValue RefreshScheduler.PerRepoState.empty let upstreamBranch = repo.GitData |> Map.tryFind ctx.Worktree.Path |> Option.bind _.UpstreamBranch let prStatus = PrStatus.lookupPrStatus repo.PrData upstreamBranch - Async.Start(SyncEngine.executeSyncPipeline post syncKey ctx.Worktree.Path ctx.RepoRoot provider repo.UpstreamRemote prStatus ct, ct) + Async.Start(SyncEngine.executeSyncPipeline post syncKey ctx.Worktree.Path ctx.RepoRoot provider repo.UpstreamRemote repo.BaseBranch prStatus ct, ct) } cancelSync = fun wtPath -> let path = WorktreePath.value wtPath @@ -503,7 +504,7 @@ let worktreeApi |> Option.map (fun repo -> repo.WorktreeList |> List.choose _.Branch - |> List.sortBy GitWorktree.branchSortKey) + |> List.sortBy (GitWorktree.branchSortKey repo.BaseBranch)) |> Option.defaultValue [] } createWorktree = fun req -> diff --git a/src/Shared/Types.fs b/src/Shared/Types.fs index 62ca591..380c267 100644 --- a/src/Shared/Types.fs +++ b/src/Shared/Types.fs @@ -152,7 +152,8 @@ type RepoWorktrees = RootFolderName: string Worktrees: WorktreeStatus list IsReady: bool - Provider: RepoProvider option } + Provider: RepoProvider option + BaseBranch: string } type SystemMetrics = { CpuPercent: float diff --git a/src/Tests/ArchiveTests.fs b/src/Tests/ArchiveTests.fs index 781598b..88a2e5b 100644 --- a/src/Tests/ArchiveTests.fs +++ b/src/Tests/ArchiveTests.fs @@ -236,7 +236,8 @@ type NavigationArchiveTests() = ArchivedWorktrees = archivedWorktrees IsReady = true IsCollapsed = false - Provider = None } + Provider = None + BaseBranch = "main" } let makeWorktreeStatus branch isArchived : WorktreeStatus = { Path = WorktreePath $"/repo/{branch}" @@ -357,7 +358,7 @@ type ArchiveE2ETests() = let makeDashboardJson (worktrees: string list) = let wts = worktrees |> String.concat "," - $"""{{"Repos":[{{"RepoId":{{"RepoId":"TestRepo"}},"RootFolderName":"TestRepo","Worktrees":[{wts}],"IsReady":true}}],"SchedulerEvents":[],"LatestByCategory":{{}},"AppVersion":"test","EditorName":"","CollapsedRepos":[]}}""" + $"""{{"Repos":[{{"RepoId":{{"RepoId":"TestRepo"}},"RootFolderName":"TestRepo","Worktrees":[{wts}],"IsReady":true,"BaseBranch":"main"}}],"SchedulerEvents":[],"LatestByCategory":{{}},"AppVersion":"test","EditorName":"","CollapsedRepos":[]}}""" let emptySyncStatus = "{}" diff --git a/src/Tests/ConfirmModalTests.fs b/src/Tests/ConfirmModalTests.fs index 342c016..97e465d 100644 --- a/src/Tests/ConfirmModalTests.fs +++ b/src/Tests/ConfirmModalTests.fs @@ -34,7 +34,8 @@ let private makeRepo repoId worktrees : RepoModel = ArchivedWorktrees = [] IsReady = true IsCollapsed = false - Provider = None } + Provider = None + BaseBranch = "main" } let private defaultModel : Model = { Repos = [ makeRepo "repo" [ makeWorktree "feature-branch" true; makeWorktree "main" false ] ] diff --git a/src/Tests/CreateWorktreeTests.fs b/src/Tests/CreateWorktreeTests.fs index fb6ce71..def5353 100644 --- a/src/Tests/CreateWorktreeTests.fs +++ b/src/Tests/CreateWorktreeTests.fs @@ -577,7 +577,8 @@ type EnterKeySuppressedWhileModalOpenTests() = ArchivedWorktrees = [] IsReady = true IsCollapsed = false - Provider = None } + Provider = None + BaseBranch = "main" } let openForm = Modal.Open { RepoId = repoId; Branches = [ "main" ]; Name = "test"; BaseBranch = "main" } diff --git a/src/Tests/NavigationTests.fs b/src/Tests/NavigationTests.fs index 0266442..dc58b86 100644 --- a/src/Tests/NavigationTests.fs +++ b/src/Tests/NavigationTests.fs @@ -31,7 +31,8 @@ module NavHelpers = ArchivedWorktrees = [] IsReady = true IsCollapsed = false - Provider = None } + Provider = None + BaseBranch = "main" } let scrollHint (_, _, hint) = hint diff --git a/src/Tests/UpstreamRemoteTests.fs b/src/Tests/UpstreamRemoteTests.fs index 8fce86a..e2fe675 100644 --- a/src/Tests/UpstreamRemoteTests.fs +++ b/src/Tests/UpstreamRemoteTests.fs @@ -153,6 +153,187 @@ type ReadUpstreamRemoteTests() = Assert.That(result, Is.EqualTo(Some "origin.backup")) +// ─── TreemonConfig: readBaseBranch ─── + +[] +[] +[] +type ReadBaseBranchTests() = + + let mutable tempDir = "" + + [] + member _.Setup() = + tempDir <- Path.Combine(Path.GetTempPath(), $"treemon-test-{Guid.NewGuid()}") + Directory.CreateDirectory(tempDir) |> ignore + + [] + member _.TearDown() = + if Directory.Exists(tempDir) then + Directory.Delete(tempDir, recursive = true) + + [] + member _.``readBaseBranch returns main when file does not exist``() = + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns main when file has no baseBranch field``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "archivedBranches": ["a"] }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns configured value``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "dev" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("dev")) + + [] + member _.``readBaseBranch returns develop branch name``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "develop" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("develop")) + + [] + member _.``readBaseBranch returns main for empty string``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns main for whitespace-only string``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": " " }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns main for malformed JSON``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """not valid json""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns main when baseBranch is not a string``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": 42 }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch returns main when baseBranch is null``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": null }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch rejects value with spaces``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "main; rm -rf /" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch rejects value starting with double dash``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "--upload-pack=evil" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch accepts hyphenated branch name``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "release-2.0" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("release-2.0")) + + [] + member _.``readBaseBranch accepts branch with dots``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "release.2.0" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("release.2.0")) + + [] + member _.``readBaseBranch coexists with other fields``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "archivedBranches": ["old"], "baseBranch": "dev", "upstreamRemote": "upstream" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("dev")) + + let remote = readUpstreamRemote tempDir + Assert.That(remote, Is.EqualTo(Some "upstream"), "Other fields should still be readable") + + [] + member _.``readBaseBranch accepts slash-based branch name``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "release/2026.05" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("release/2026.05")) + + [] + member _.``readBaseBranch accepts nested slash branch name``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "feature/team/auth" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("feature/team/auth")) + + [] + member _.``readBaseBranch rejects --all flag injection``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "--all" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + [] + member _.``readBaseBranch rejects single dash prefix``() = + File.WriteAllText( + Path.Combine(tempDir, ".treemon.json"), + """{ "baseBranch": "-branch" }""") + + let result = readBaseBranch tempDir + Assert.That(result, Is.EqualTo("main")) + + // ─── Git command construction: mainRef, fetch, merge targets ─── [] @@ -162,15 +343,23 @@ type GitCommandConstructionTests() = [] member _.``mainRef with origin produces origin/main``() = - Assert.That(mainRef "origin", Is.EqualTo("origin/main")) + Assert.That(mainRef "origin" "main", Is.EqualTo("origin/main")) [] member _.``mainRef with upstream produces upstream/main``() = - Assert.That(mainRef "upstream", Is.EqualTo("upstream/main")) + Assert.That(mainRef "upstream" "main", Is.EqualTo("upstream/main")) [] member _.``mainRef with custom remote produces custom/main``() = - Assert.That(mainRef "my-fork", Is.EqualTo("my-fork/main")) + Assert.That(mainRef "my-fork" "main", Is.EqualTo("my-fork/main")) + + [] + member _.``mainRef with custom baseBranch produces remote/baseBranch``() = + Assert.That(mainRef "origin" "dev", Is.EqualTo("origin/dev")) + + [] + member _.``mainRef with upstream and develop produces upstream/develop``() = + Assert.That(mainRef "upstream" "develop", Is.EqualTo("upstream/develop")) [] member _.``SyncEngine buildFetchArgs with origin produces fetch origin``() = @@ -200,6 +389,30 @@ type GitCommandConstructionTests() = Assert.That(args, Does.Contain("Q:\\code\\myproject")) Assert.That(args, Does.Contain("remote get-url upstream")) + [] + member _.``branchSortKey gives configured baseBranch priority 0``() = + Assert.That(branchSortKey "dev" "dev", Is.EqualTo((0, "dev"))) + + [] + member _.``branchSortKey gives main priority 0 when baseBranch is main``() = + Assert.That(branchSortKey "main" "main", Is.EqualTo((0, "main"))) + + [] + member _.``branchSortKey gives main priority 1 when baseBranch is not main``() = + Assert.That(branchSortKey "dev" "main", Is.EqualTo((1, "main"))) + + [] + member _.``branchSortKey gives master priority 1``() = + Assert.That(branchSortKey "dev" "master", Is.EqualTo((1, "master"))) + + [] + member _.``branchSortKey gives develop priority 2 when not baseBranch``() = + Assert.That(branchSortKey "main" "develop", Is.EqualTo((2, "develop"))) + + [] + member _.``branchSortKey gives feature branch priority 4``() = + Assert.That(branchSortKey "main" "feature/xyz", Is.EqualTo((4, "feature/xyz"))) + // ─── RefreshScheduler: PerRepoState defaults and UpdateUpstreamRemote ───