Skip to content

Commit 09b0adf

Browse files
committed
Changed git repositories to support multiple repositories at once
1 parent f0b804d commit 09b0adf

6 files changed

Lines changed: 95 additions & 34 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
"ts-node": "^10.9.1",
7171
"tslib": "^2.6.2",
7272
"tsx": "^4.7.2",
73-
"typescript": "^5",
73+
"typescript": "5.9.3",
7474
"vitest": "^1.4.0"
7575
},
7676
"optionalDependencies": {

src/resources/git/repository/git-repository-schema.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
"type": "string",
1111
"description": "Remote repository to clone repo from."
1212
},
13+
"repositories": {
14+
"type": "array",
15+
"description": "Remote repositories to clone. This is a convenience property for cloning multiple repositories at once.",
16+
"items": {
17+
"type": "string"
18+
}
19+
},
1320
"parentDirectory": {
1421
"type": "string",
1522
"description": "Parent directory to clone into. The folder name will use default git semantics which extracts the last part of the clone url. Only one of parentDirectory or directory can be specified"
@@ -26,6 +33,6 @@
2633
"additionalProperties": false,
2734
"oneOf": [
2835
{ "required": ["repository", "directory"] },
29-
{ "required": ["repository", "parentDirectory"] }
36+
{ "required": ["repositories", "parentDirectory"] }
3037
]
3138
}

src/resources/git/repository/git-repository.ts

Lines changed: 74 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,74 @@ import path from 'node:path';
55
import { FileUtils } from '../../../utils/file-utils.js';
66
import Schema from './git-repository-schema.json';
77

8-
export interface GitCloneConfig extends ResourceConfig {
8+
export interface GitRepositoryConfig extends ResourceConfig {
99
autoVerifySSH: boolean
1010
directory?: string,
1111
parentDirectory?: string,
12+
repositories?: string[],
1213
repository: string,
1314
}
1415

15-
export class GitCloneResource extends Resource<GitCloneConfig> {
16-
getSettings(): ResourceSettings<GitCloneConfig> {
16+
export class GitRepositoryResource extends Resource<GitRepositoryConfig> {
17+
getSettings(): ResourceSettings<GitRepositoryConfig> {
1718
return {
1819
id: 'git-repository',
1920
operatingSystems: [OS.Darwin, OS.Linux],
2021
schema: Schema,
2122
parameterSettings: {
23+
repositories: { type: 'array' },
2224
parentDirectory: { type: 'directory' },
2325
directory: { type: 'directory' },
2426
autoVerifySSH: { type: 'boolean', default: true, setting: true },
2527
},
26-
importAndDestroy:{
28+
importAndDestroy: {
2729
requiredParameters: ['directory']
2830
},
2931
allowMultiple: {
3032
matcher: (desired, current) => {
3133
const desiredPath = desired.parentDirectory
32-
? path.resolve(desired.parentDirectory, this.extractBasename(desired.repository!)!)
34+
? desired.repositories?.map((r) => path.resolve(desired.parentDirectory!, this.extractBasename(r)!))
3335
: path.resolve(desired.directory!);
3436

3537
const currentPath = current.parentDirectory
36-
? path.resolve(current.parentDirectory, this.extractBasename(current.repository!)!)
38+
? current.repositories?.map((r) => path.resolve(current.parentDirectory!, this.extractBasename(r)!))
3739
: path.resolve(current.directory!);
3840

3941
const isNotCaseSensitive = process.platform === 'darwin';
4042
if (isNotCaseSensitive) {
41-
return desiredPath.toLowerCase() === currentPath.toLowerCase()
43+
if (!Array.isArray(desiredPath) && !Array.isArray(currentPath)) {
44+
return desiredPath!.toLowerCase() === currentPath!.toLowerCase()
45+
}
46+
47+
if (Array.isArray(desiredPath) && Array.isArray(currentPath)) {
48+
const currentLowered = new Set(currentPath.map((c) => c.toLowerCase()))
49+
return desiredPath.some((d) => currentLowered.has(d.toLowerCase()))
50+
}
4251
}
43-
52+
53+
if (Array.isArray(desiredPath) && Array.isArray(currentPath)) {
54+
return desiredPath.some((d) => currentPath.includes(d))
55+
}
56+
4457
return desiredPath === currentPath;
4558
},
46-
findAllParameters: async () => {
59+
async findAllParameters() {
4760
const $ = getPty();
4861
const { data } = await $.spawnSafe('find ~ -type d \\( -path $HOME/Library -o -path $HOME/Pictures -o -path $HOME/Utilities -o -path "$HOME/.*" \\) -prune -o -name .git -print')
4962

50-
return data
63+
const directories = data
5164
?.split(/\n/)?.filter(Boolean)
5265
?.map((p) => path.dirname(p))
5366
?.map((directory) => ({ directory }))
5467
?? [];
68+
69+
const groupedDirectories = Object.groupBy(directories, (d) => path.dirname(d.directory));
70+
const multipleRepositories = Object.entries(groupedDirectories).filter(([_, dirs]) => (dirs?.length ?? 0) > 1)
71+
.map(([parent]) => ({ parentDirectory: parent }))
72+
const singleRepositories = Object.entries(groupedDirectories).filter(([_, dirs]) => (dirs?.length ?? 0) === 1)
73+
.map(([directory]) => ({ directory }))
74+
75+
return [...multipleRepositories, ...singleRepositories];
5576
}
5677
},
5778
dependencies: [
@@ -63,27 +84,47 @@ export class GitCloneResource extends Resource<GitCloneConfig> {
6384
}
6485
}
6586

66-
override async refresh(parameters: Partial<GitCloneConfig>): Promise<Partial<GitCloneConfig> | null> {
87+
override async refresh(parameters: Partial<GitRepositoryConfig>): Promise<Partial<GitRepositoryConfig> | null> {
6788
const $ = getPty();
6889

6990
if (parameters.parentDirectory) {
70-
const folderName = this.extractBasename(parameters.repository!);
71-
if (!folderName) {
72-
throw new Error('Invalid git repository or remote name. Un-able to parse');
91+
// Check if parent directory exists
92+
const parentExists = await FileUtils.checkDirExistsOrThrowIfFile(parameters.parentDirectory);
93+
if (!parentExists) {
94+
return null;
7395
}
7496

75-
const fullPath = path.join(parameters.parentDirectory, folderName);
97+
// Find all git repositories in the parent directory
98+
const { data } = await $.spawnSafe(`find "${parameters.parentDirectory}" -maxdepth 2 -type d -name .git`, { cwd: parameters.parentDirectory });
7699

77-
const exists = await FileUtils.checkDirExistsOrThrowIfFile(fullPath);
78-
if (!exists) {
100+
const gitDirs = data?.split(/\n/)?.filter(Boolean) ?? [];
101+
if (gitDirs.length === 0) {
102+
return null;
103+
}
104+
105+
// Get repository URLs for all found git directories
106+
const repositories: string[] = [];
107+
for (const gitDir of gitDirs) {
108+
const repoPath = path.dirname(gitDir);
109+
const { data: url } = await $.spawnSafe('git config --get remote.origin.url', { cwd: repoPath });
110+
if (url && url.trim()) {
111+
repositories.push(url.trim());
112+
}
113+
}
114+
115+
if (repositories.length === 0) {
79116
return null;
80117
}
81118

82-
const { data: url } = await $.spawn('git config --get remote.origin.url', { cwd: fullPath });
119+
console.log('Refresh', {
120+
parentDirectory: parameters.parentDirectory,
121+
repositories,
122+
autoVerifySSH: parameters.autoVerifySSH,
123+
})
83124

84125
return {
85126
parentDirectory: parameters.parentDirectory,
86-
repository: url.trim(),
127+
repositories,
87128
autoVerifySSH: parameters.autoVerifySSH,
88129
}
89130
}
@@ -107,29 +148,34 @@ export class GitCloneResource extends Resource<GitCloneConfig> {
107148
}
108149

109150

110-
override async create(plan: CreatePlan<GitCloneConfig>): Promise<void> {
151+
override async create(plan: CreatePlan<GitRepositoryConfig>): Promise<void> {
111152
const $ = getPty();
112153
const config = plan.desiredConfig;
113154

114-
if (plan.desiredConfig.autoVerifySSH) {
115-
await this.autoVerifySSHForFirstAttempt(config.repository)
116-
}
117-
118155
if (config.parentDirectory) {
119156
const parentDirectory = path.resolve(config.parentDirectory);
120157
await FileUtils.createDirIfNotExists(parentDirectory);
121-
await $.spawn(`git clone ${config.repository}`, { cwd: parentDirectory });
158+
159+
// Clone all repositories in the list
160+
const repositories = (config as any).repositories || [config.repository];
161+
for (const repository of repositories) {
162+
if (plan.desiredConfig.autoVerifySSH) {
163+
await this.autoVerifySSHForFirstAttempt(repository);
164+
}
165+
166+
await $.spawn(`git clone ${repository}`, { cwd: parentDirectory });
167+
}
122168
} else {
123169
const directory = path.resolve(config.directory!);
124170
await $.spawn(`git clone ${config.repository} ${directory}`);
125171
}
126172
}
127173

128-
override async destroy(plan: DestroyPlan<GitCloneConfig>): Promise<void> {
174+
override async destroy(plan: DestroyPlan<GitRepositoryConfig>): Promise<void> {
129175
// Do nothing here. We don't want to destroy a user's repository.
130176
// TODO: change this to skip the destroy only if the user's repo has pending changes (check via git)
131-
throw new Error(`The git-clone resource is not designed to delete folders.
132-
Please delete ${plan.currentConfig.directory ?? (plan.currentConfig.parentDirectory! + this.extractBasename(plan.currentConfig.repository))} manually and re-apply`);
177+
throw new Error(`The git-clone resource is not designed to delete folders.
178+
Please delete ${plan.currentConfig.directory ?? (plan.currentConfig.repositories?.map((r) => path.resolve(plan.currentConfig.parentDirectory!, this.extractBasename(r)!)).join(', '))} manually and re-apply`);
133179
}
134180

135181
// Converts https://github.com/kevinwang5658/codify-homebrew-plugin.git => codify-homebrew-plugin

test/git/git-repository.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { afterAll, describe, expect, it } from 'vitest';
22
import { PluginTester, testSpawn } from 'codify-plugin-test';
33
import * as path from 'node:path';
44
import * as fs from 'node:fs/promises';
@@ -12,7 +12,7 @@ describe('Git repository integration tests', async () => {
1212
{
1313
type: 'git-repository',
1414
parentDirectory: '~/projects/test',
15-
repository: 'https://github.com/kevinwang5658/untitled.git'
15+
repositories: ['https://github.com/kevinwang5658/untitled.git', 'https://github.com/octocat/Hello-World.git']
1616
}
1717
], {
1818
skipUninstall: true, // Can't directly delete repos via codify currently.
@@ -39,6 +39,9 @@ describe('Git repository integration tests', async () => {
3939
}
4040
], {
4141
skipUninstall: true,
42+
validatePlan: async (plans) => {
43+
console.log('plans', plans);
44+
},
4245
validateApply: async () => {
4346
const location = path.join(os.homedir(), 'projects', 'nested', 'codify-plugin');
4447
const lstat = await fs.lstat(location);
@@ -50,4 +53,9 @@ describe('Git repository integration tests', async () => {
5053
}
5154
});
5255
})
56+
57+
afterAll(async () => {
58+
await fs.rm(path.join(os.homedir(), 'projects', 'test'), { recursive: true, force: true });
59+
await fs.rm(path.join(os.homedir(), 'projects', 'nested'), { recursive: true, force: true });
60+
})
5361
})

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"outDir": "dist",
1414
"rootDir": "src",
1515
"strict": true,
16-
"target": "es2022"
16+
"target": "es2024"
1717
},
1818
"include": [
1919
"src/**/*.ts"

0 commit comments

Comments
 (0)