Allow all branch names to be glob patterns

01b85955ec11cb99ee24a5af62d7cbbb0e92151e

Tucker McKnight <tmcknight@instructure.com> | Mon Jan 19 2026

Allow all branch names to be glob patterns

Change the way that branches are matched to those patterns so that,
if multiple patterns match a branch name, the most specific pattern
is the one that is used.

Also allows new fields on a branch: description and compareTo. Prints
out the branch description on the branches page.

Also makes the max value work -- kicks out branches from the list if
there are already the max number of branches in the list. TODO:
need to make sure this only kicks out the branches that are the oldest
(that is, not recently committed to).
js_templates/branches.ts:19
Before
18
19
20
21
22
23
                  ${branch.branchName === data.branchInfo.branchName ? '<div class="badge rounded-pill bg-secondary mx-1">current</div>' : ''}
                  ${branch.branchName === data.reposConfig.repos[branch.repoName].defaultBranch ? '<div class="badge rounded-pill bg-info text-dark mx-1">default</div>' : ''}
                </div>
                <div class="card-body">Data about branch goes here</div>
              </div>
            `
            : ''
After
18
19
20
21
22
23
                  ${branch.branchName === data.branchInfo.branchName ? '<div class="badge rounded-pill bg-secondary mx-1">current</div>' : ''}
                  ${branch.branchName === data.reposConfig.repos[branch.repoName].defaultBranch ? '<div class="badge rounded-pill bg-info text-dark mx-1">default</div>' : ''}
                </div>
                <div class="card-body">${branch.description || ''}</div>
              </div>
            `
            : ''
schemas/ReposConfiguration.json:14
Before
13
14
15
16
17
18



19
20
21



22
23
24
25
26
27
              {
                "additionalProperties": false,
                "properties": {
                  "glob": {
                    "type": "string"
                  },
⁣
⁣
⁣
                  "max": {
                    "type": "number"
                  }
⁣
⁣
⁣
                },
                "required": [
                  "glob",
                  "max"
                ],
                "type": "object"
              }
After
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

31
32
              {
                "additionalProperties": false,
                "properties": {
                  "compareTo": {
                    "type": "string"
                  },
                  "description": {
                    "type": "string"
                  },
                  "max": {
                    "type": "number"
                  },
                  "pattern": {
                    "type": "string"
                  }
                },
                "required": [
                  "pattern"
⁣
                ],
                "type": "object"
              }
src/branches.ts:10
Before
9
10
11
12
13
14
15



16
17

  cachedBranches = repos.flatMap((repo) => {
    return repo.branches.map((branch) => {
      return {
        branchName: branch.name,
        repoName: repo.name,
      }
⁣
⁣
⁣
    })
  })
After
9
10
11
12
13
14
15
16
17
18
19
20

  cachedBranches = repos.flatMap((repo) => {
    return repo.branches.map((branch) => {
      const result = {
        branchName: branch.name,
        repoName: repo.name,
      }
      if (branch.description) { result['description'] = branch.description }
      if (branch.compareTo) { result['compareTo'] = branch.compareTo }
      return result
    })
  })
src/configTypes.ts:11
Before
10
11
12
13
14
15
 *   repos: {
 *     "My Git Project": {
 *       defaultBranch: 'main',
 *       branches: ['main', 'develop']
 *     },
 *   },
 * }
After
10
11
12
13
14
15
 *   repos: {
 *     "My Git Project": {
 *       defaultBranch: 'main',
 *       branchesToPull: ['main', 'develop']
 *     },
 *   },
 * }
src/configTypes.ts:53
Before
52
53
54
55
56
57
  location: string,
  description?: string,
  defaultBranch: string,
  branchesToPull: Array<string | {glob: string, max: number}>,
  tags?: Array<string | {glob: string, max: number}>,
  languageExtensions?: {
    [fileExtension: string]: string
After
52
53
54
55
56
57
  location: string,
  description?: string,
  defaultBranch: string,
  branchesToPull: Array<string | {pattern: string, max?: number, compareTo?: string, description?: string}>,
  tags?: Array<string | {glob: string, max: number}>,
  languageExtensions?: {
    [fileExtension: string]: string
src/dataTypes.ts:5
Before
4
5
6


7
8
  defaultBranch: string,
  branches: Array<{
    name: string,
⁣
⁣
    head: string,
    fileList: Array<string>,
  }>,
After
4
5
6
7
8
9
10
  defaultBranch: string,
  branches: Array<{
    name: string,
    description?: string,
    compareTo?: string,
    head: string,
    fileList: Array<string>,
  }>,
src/repos.ts:14
Before
13
14
15
16
17
18
19
20

const exec = util.promisify(childProcess.exec)

const branchesForReposMap: Map<string, string[]> = new Map()

const getBranchNames = async (repoConfig: GitConfig, repoName: string): Promise<Array<string>> => {
  const cachedBranchNames = branchesForReposMap.get(repoName)
  if (cachedBranchNames !== undefined) {
    return cachedBranchNames
After
13
14
15
16
17
18
19
20

const exec = util.promisify(childProcess.exec)

const branchesForReposMap: Map<string, Array<{name: string, description?: string, compareTO?: string}>> = new Map()

const getBranches = async (repoConfig: GitConfig, repoName: string): Promise<Array<{name: string, description?: string, compareTo?: string}>> => {
  const cachedBranchNames = branchesForReposMap.get(repoName)
  if (cachedBranchNames !== undefined) {
    return cachedBranchNames
src/repos.ts:24
Before
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




  // Get all branches available in the repository
  const allBranches = (await exec(`git -C ${repoConfig.location} branch --format="%(refname:short)"`)).stdout.split("\n")
⁣
⁣
⁣
⁣
  const matchingBranchesFromGlobs: Map<string, string[]> = new Map()
⁣
  const literalBranchNames: string[] = []

  repoConfig.branchesToPull.forEach((branch) => {
⁣
⁣
    if (typeof branch === "string") {
      literalBranchNames.push(branch)
    }
    else {
⁣
      // If we're here, then the "branch" was an object like:
      // { glob: string, max: number }
⁣
⁣
⁣
⁣
      // TODO: figure out which branch is the newest/oldest, and
⁣
⁣
⁣
⁣
// keep them less than `max` by kicking out the oldest from the list.
⁣
      const glob = branch['glob']
      const matching = allBranches.filter((possibleBranch) => {
        const match = path.matchesGlob(possibleBranch, glob)
        return match
      })
      matchingBranchesFromGlobs.set(
        glob,
⁣
⁣
⁣
        (matchingBranchesFromGlobs.get(glob) || []).concat(matching)
⁣
⁣
⁣
      )
⁣
    }
  })

⁣
⁣
⁣
⁣
⁣
  const matchedBranchesFromGlobs = Array.from(matchingBranchesFromGlobs.values()).flat()
⁣
⁣
⁣
⁣
⁣
  const branchNames: string[] = Array.from(new Set(matchedBranchesFromGlobs.concat(literalBranchNames)))

  if (branchesForReposMap.get(repoName) === undefined) {
    branchesForReposMap.set(repoName, branchNames)
  }

  return branchNames
}

⁣
⁣
⁣
⁣
let cachedRepos: Array<Repository> | null = null
After
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

  // Get all branches available in the repository
  const allBranches = (await exec(`git -C ${repoConfig.location} branch --format="%(refname:short)"`)).stdout.split("\n")

  // Sort the list of branch descriptions from branchesToPull by the length
  // of their patterns.
  // Then, for each branch from the repository, see if it matches a pattern.
  // If that pattern has rules associated with it (like a description or a max),
  // apply those.
  type RulesObject = {max?: number, description?: string, compareTo?: string}let branchRules: Array<{pattern: string, matches: Array<string>, rules: RulesObject}> = repoConfig.branchesToPull.map((branchDescription) => {
    const rules: RulesObject = {}

    if (typeof branchDescription !== 'string') {
⁣
⁣
⁣
      if (branchDescription.max) { rules.max = branchDescription.max }
      if (branchDescription.description) { rules.description = branchDescription.description }
      if (branchDescription.compareTo) { rules.compareTo = branchDescription.compareTo }
    }

    return {
      pattern: (typeof branchDescription === 'string' ? branchDescription : branchDescription.pattern),
      matches: [],
      rules,
    }
  })

  branchRules = branchRules.toSorted((a, b) => b.pattern.length - a.pattern.length)

  allBranches.forEach((branchName) => {
    const matchingPatternIndex = branchRules.findIndex((branchRule) => {
      return path.matchesGlob(branchName, branchRule.pattern)
⁣
    })

⁣
    if (matchingPatternIndex === -1) { return }

    const matchedRule = branchRules[matchingPatternIndex]
    matchedRule.matches.push(branchName)
    if (
      matchedRule.rules?.max
      && matchedRule.rules?.max < matchedRule.matches.length
    ) {
      matchedRule.matches.pop()
    }
  })

  const branches: Array<{
    name: string,
    description?: string,
    compareTo?: string,
  }> = branchRules.map((branchRule) => {
    return branchRule.matches.map((match) => {
      const result = {name: match}
      if (branchRule.rules.description) { result['description'] = branchRule.rules.description }
      if (branchRule.rules.compareTo) { result['compareTo'] = branchRule.rules.compareTo }
      return result
    }).flat()
  }).flat()

  if (branchesForReposMap.get(repoName) === undefined) {
    branchesForReposMap.set(repoName, branches)
  }

  return branches
}

const getBranchNames = async (repoConfig, repoName) => {
  return (await getBranches(repoConfig, repoName)).map(branch => branch.name)
}

let cachedRepos: Array<Repository> | null = null
src/repos.ts:68
Before
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84



85
86

  for (const repoName of repoNames) {
    const repoLocation = getLocation(reposConfig, outputDir, repoName)
    const branchNames = await getBranchNames(reposConfig.repos[repoName], repoName)
    const commits: Repository['commits'] = new Map()
    for (const branchName of branchNames) {
      await addBranchToCommitsMap(branchName, repoLocation, commits)
    }

    const branches = await Promise.all(branchNames.map(async (branchName) => {
      const repoLocation = getLocation(reposConfig, outputDir, repoName)
      const branchHeadRes = await exec(`git -C ${repoLocation} show-ref refs/heads/${branchName}`)
      const branchHead = branchHeadRes.stdout.split(" ")[0]
      return {
        name: branchName,
        head: branchHead,
        fileList: await getFileList(branchName, repoLocation)
      }
⁣
⁣
⁣
    }))

    cachedRepos.push({
After
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89

  for (const repoName of repoNames) {
    const repoLocation = getLocation(reposConfig, outputDir, repoName)
    const branchesToAdd = await getBranches(reposConfig.repos[repoName], repoName)
    const commits: Repository['commits'] = new Map()
    for (const branchName of await getBranchNames(reposConfig.repos[repoName], repoName)) {
      await addBranchToCommitsMap(branchName, repoLocation, commits)
    }

    const branches = await Promise.all(branchesToAdd.map(async (branch) => {
      const repoLocation = getLocation(reposConfig, outputDir, repoName)
      const branchHeadRes = await exec(`git -C ${repoLocation} show-ref refs/heads/${branch.name}`)
      const branchHead = branchHeadRes.stdout.split(" ")[0]
      const result = {
        name: branch.name,
        head: branchHead,
        fileList: await getFileList(branch.name, repoLocation)
      }
      if (branch.description) { result['description'] = branch.description }
      if (branch.compareTo) { result['compareTo'] = branch.compareTo }
      return result
    }))

    cachedRepos.push({
wiki/projects/branch-globs.md:42
Before
41
42




























    - this is a good reason to put the deployed repo config on the public site, too.
      I currently have different example site configs between my laptop and the deployed
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
      site.
After
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
    - this is a good reason to put the deployed repo config on the public site, too.
      I currently have different example site configs between my laptop and the deployed
      site.

## Jan 18, 2026

*An idea:* allow the object with a glob in it to also specify things like the description
and which parent branch the branch should be compared to.

Multiple branch patterns (let's use the word "pattern" instead of "glob") can match
the same branch -- the more specific one should be used.

E.g.:

```
branchesToPull: [
  {
    pattern: "deploy/**",
    description: "A deployed branch",
  },
  {
    pattern: "deploy/v2**",
    description: "A version 2 deployed branch",
  }
]
```

In the above example, branch `deploy/v1.0.1` should have the description "A deployed
branch," but branch `deploy/v2.0.1` should have the description "A version 2
deployed branch."

Is the "more specific pattern" just always the one that is longer? Try this for now.