Visualizar um repositório com a ferramenta Gource é bastante simples. Basta rodar o comando gource
dentro do repositório e voilà. Mas e se quisermos gerar uma visualização de centenas de repositórios de uma vez? É este cenário que exploro nesse post.
Organizações que trabalham com microsserviços tendem a distribuir o desenvolvimento em um grande número de pequenos repositórios. Cada microsserviço e cada microfrontend reside em um repositório próprio. Isso sem contar com bibliotecas e outros repositórios de apoio.
Renderizando múltiplos repositórios
No ano passado, escrevi um post sobre o uso básico do Gource. A seguir, mostro como expandir a abordagem para centenas de repositórios.
Custom logs
O Gource permite gerar visualizações de um repositório facilmente. Mas se quisermos que ele renderize vários repositórios simultaneamente, precisamos "enganá-lo", criando um histórico unificado, simulando um Mega Repositório.
Esta estratégia está prevista na documentação do Gource.
Com o comando a seguir, podemos exportar o histórico de modificações de cada repositório num formato fácil de manipular:
gource --output-custom-log log1.txt repo (1)
1 | log1.txt é o arquivo de log customizado que será gerado a partir do histórico do repositório na pasta repo |
Será necessário rodar esse comando individualmente para cada repositório que desejemos incluir na visualização. Isso implica que será necessário clonar cada um deles. Essa é a parte chata do processo, e que mais vale ser automatizada.
Isso pode ser feito com a linguagem de script de sua preferência. No meu caso, meu colega Gustavo Baroni Bruder e eu automatizamos esse processo em Python, usando a API do Azure DevOps.
Clonando 165 repositórios do Azure DevOps
Na Ambev Tech utilizamos o Azure DevOps para organizar nosso trabalho. A título de exemplo vou apresentar o passo-a-passo que usamos para clonar todos os 165 repositórios necessários para gerar a visualização usando a API do Azure DevOps.
Primeiro, precisamos dos IDs dos projetos envolvidos que serão utilizados. Se você trabalha com apenas um projeto, pode obter essa informação facilmente na URL do projeto ao acessá-lo no portal do Azure DevOps. Caso tenha vários, essa seção serve de exemplo de como automatizar esse processo.
projects_url f'https://dev.azure.com/{organization}/_apis/projects?api-version=6.0' (1)
result = get_from_azure_devops(projects_url)
organization_data = json.loads(result) (2)
def encode_PAT(pat: str):
### PAT = Personal Access Token
pat_bytes = pat.encode('ascii')
base64_bytes = base64.b64encode(pat_bytes)
base64_message = base64_bytes.decode('ascii')
return base64_message
def get_from_azure_devops(url: str):
personal_access_token = os.getenv('PERSONAL_ACCESS_TOKEN') (3)
encoded_token = encode_PAT(f':{PERSONAL_ACCESS_TOKEN}')
authorization = f'Authorization: Basic {ENCODED_TOKEN}'
req = urllib.request.Request(url, headers={'Authorization': f'Basic {ENCODED_TOKEN}'})
with urllib.request.urlopen(req) as response:
return response.read()
1 | Este endpoint vai retornar a lista de projetos da organização, respeitando as permissões do token fornecido |
2 | O formato do JSON retornado pode ser visto mais abaixo |
3 | O PAT, personal access token pode ser gerado no portal do Azure DevOps, conforme imagem abaixo: |
{
"count": 5,
"value": [
{
"id": "6525b4fe-aa1c-4391-8e8d-1d400506f1ab",
"name": "PROJETO-X",
"description": "Descrição do projeto",
"url": "https://dev.azure.com/ORGANIZATION-NAME/_apis/projects/6525b4fe-aa1c-4391-8e8d-1d400506f1ab",
"state": "wellFormed",
"revision": 25130,
"visibility": "private",
"lastUpdateTime": "2021-06-28T14:54:17.09Z"
}
//...
]
}
Com base nessa informação, podemos listar todos os repositórios abaixo de cada projeto da organização:
repositories_by_project = {}
projects = list(organization_data["value"])
for project in projects:
project_name = project["name"]
project_repositories_url = f'https://dev.azure.com/{organization}/{project}/_apis/git/repositories?api-version=6.0'
result = get_from_azure_devops(project_repositories_url)
repositories_data = json.loads(result)
repositories = [ (1)
{'name': repository['name'], 'url': repository['remoteUrl']}
for repository in repositories_data["value"]
]
repositories_by_project[project_name] = repositories (2)
1 | Extraímos do payload retornado apenas as informações necessárias para clonar os repositórios: nome e url |
2 | Guardamos toda a informação relevante num dicionário com a estrutura abaixo |
{
"PROJETO-1": [
{
"name": "repositorio1",
"url": "https://NOME-ORGANIZACAO@dev.azure.com/NOME-ORGANIZACAO/NOME-PROJETO/_git/repositorio1"
},
{
"name": "repositorio2",
"url": "https://NOME-ORGANIZACAO@dev.azure.com/NOME-ORGANIZACAO/NOME-PROJETO/_git/repositorio2"
},
]
}
Com isso, temos toda a informação necessária para clonar todos os repositórios. Continuando nossa automação com Python, podemos executar utilizar o CLI do Git como um subprocesso.
# continuando os trechos anteriores
for project in repositories_by_project:
for repository in repositories_by_project[project]:
remote_url = repository["url"]
name = repository["name"]
try_clone_repository(remote_url, name)
def execute_in_shell(command: str): (1)
popen = Popen(command, stdout=PIPE, universal_newlines=True)
for stdout_line in iter(popen.stdout.readline, ""):
yield stdout_line
popen.stdout.close()
return_code = popen.wait()
if return_code:
raise CalledProcessError(return_code, command)
def try_clone_repository(git_url: str, repository_name: str):
try:
target_directory = f'./repositories/{repository_name}' (2)
if os.path.isdir(target_directory) and len(os.listdir(target_directory)) > 0:
print(f'{target_directory} already cloned. Skipping.')
return
git_command = f'git clone "{git_url}" {target_directory}'
print(f'Executing command: {git_command}')
for path in execute_in_shell(git_command):
print(path, end="")
except Exception as exception:
print(exception)
1 | Retorna a saída do processo e facilita a depuração |
2 | Vai clonar cada um dos repositórios na pasta repositories |
Feito isso, teremos todos os repositórios clonados. Agora podemos seguir com a manipulação do histórico.
Processando os históricos de modificação do Git
Ao rodar o comando gource --output-custom-log log1.txt repo
, será gerado um CSV num formato bem simples.
**1629995348**|Nome usuário 1|A|/.gitignore (1)
1629995348|**Nome usuário 1**|A|/README.md (2)
1630016091|usuario.2|**M**|/.gitignore (3)
1630016091|usuario.2|A|**/backend/.editorconfig** (4)
1630016091|usuario.2|A|/backend/.gitignore
1630016091|usuario.2|A|/backend/ServiceX.sln
1630016091|usuario.2|A|/backend/NuGet.Config
1630016091|usuario.2|A|/backend/devops/docker/Dockerfile
1630016091|usuario.2|D|/backend/devops/helm/servicex/.helmignore
1630016091|usuario.2|D|/backend/devops/helm/servicex/Chart.yaml
1 | A primeira coluna é o timestamp do commit |
2 | A segunda coluna é o nome do usuário. Uma mesma pessoa pode aparecer várias vezes se utilizar diferentes configurações no Git ao longo do tempo. |
3 | A terceira coluna descreve a ação: A=adição, M=modificação, D=deleção. Essa informação é usada pelo Gource para colorizar os feixes de atuação dos usuários. |
4 | A última coluna traz o caminho do arquivo alterado, relativo à raiz do repositório. Esse caminho determina como a árvore de arquivos será representada na visualiação gerada pelo Gource. |
A próxima etapa é juntar todos os repositórios. Uma vez que temos todos os repositórios clonados, podemos adicionar ao nosso script Python a renderização dos logs de cada repositório, para depois juntar.
# continuando os trechos anteriores
for project in repositories_by_project:
for repository in repositories_by_project[project]:
remote_url = repository["url"]
name = repository["name"]
try_clone_repository(remote_url, name)
render_custom_log(repository, project) (1)
def render_custom_log(repository_name, project_name):
print(f'Generating log file for repository {repository_name}.txt')
try:
target_file = f'./render/{repository_name}.txt'
if os.path.isfile(target_file):
print(f'Repository {repository} already processed')
return
if not os.path.isdir(repository_directory):
raise Exception(f'Unable to render custom log for repository {repository_name}. {repository_directory} not found.')
else:
files = os.listdir(repository_directory)
has_files = len(files) > 1 # every repository has at least .git
if not has_files:
print(f'Skipping log genereation for repository {repository_name}. Repository is empty')
return
gource_command = f'gource --output-custom-log "{target_file}" "./repositories/{repository_name}"' (2)
proc = Popen(gource_command, stdout=PIPE, stderr=PIPE, shell=True)
out, err = proc.communicate()
if proc.returncode == 0:
print(f'Command executed successfully: {gource_command}')
sleep(1000) # necessary to ensure that the generated file is closed
inject_repository_name(target_file, project_name)
else:
raise Exception(f"Error executing command. Exitcode: {proc.returncode}\n {err.decode('utf-8')}\n Command: {gource_command}")
except Exception as e:
print(e)
1 | Após clonar os repositórios, podemos renderizar os logs customizados que serão utilizados nas próximas etapas |
2 | Podemos invocar o gource como um subprocesso |
Criando um histórico unificado (aka Mega Repositório)
Se tudo der certo, teremos uma série de arquivos texto, com o histórico de modificação de cada repositório. Nessa hora, temos de tomar uma decisão:
-
Simplesmente juntar o histórico de todos os repositórios: será como se fosse um monorepo. Por exemplo, todas as pastas
/src
serão mescladas. A desvantagem dessa abordagem é que não corresponde à realidade. -
Editar cada um dos arquivos de histórico, adicionando o nome do repositório como se fosse uma pasta raiz do Mega Repositório. Assim, garantimos que cada repositório será representado individualmente.
Optamos pela opção 2 porque gera uma visualização bem mais fácil de compreender e que representa mais fielmente o trabalho realizado.
A documentação do Gource indica que podemos usar o sed
para fazer essa edição, como visto abaixo:
sed
sed -i -r "s#(.+)\|#\1|/repo1#" log1.txt
Apesar de essa estratégia funcionar, não é tão legível, e podemos fazer o mesmo com Python:
render_custom_log
# ...
if proc.returncode == 0:
print(f'Command executed successfully: {gource_command}')
sleep(1) # ensure that the generated file is closed
inject_repository_name(target_file, project_name) (1)
def inject_repository_name(file_path, project_name): (2)
with open(file_path, 'r') as file_read:
content = file_read.read()
with open(file_path, 'w') as file:
new_content = content \
.replace('|A|', f'|A|/{project_name}') \
.replace('|M|', f'|M|/{project_name}') \
.replace('|D|', f'|D|/{project_name}')
file.write(new_content)
1 | Após confirmar a geração do arquivo, podemos manipulá-lo |
2 | Esta função faz o mesmo que aquele comando sed , adicionando o nome do repositório como pasta raiz de todas as modificações do arquivo. |
Agora estamos prontos para juntar tudo um único repositório, criar nosso Mega Repositório. Uma forma de fazer isso é imprimir o conteúdo de todos os arquivos, ordená-los e jogar o resultado num arquivo combinado contendo o histórico de todos repositórios.
Algo nessa linha, em que ocorre um processamento em três fases:
-
Todos os logs são impressos
-
São ordenados numericamente (a primeira coluna ajuda, sendo um timestamp)
-
Os logs são combinados num único histórico coerente
cat log1.txt log2.txt log3.txt | sort -n > combined.txt
Mas seguindo na linha de automatizar todo o processo em Python, vamos seguir com nossa automação.
custom_log_files = os.listdir('./render') (1)
all_lines = []
for log_file in custom_log_files:
lines = read_lines(f'./render/{log_file}')
all_lines.extend(lines) (2)
all_lines.sort() (3)
combined_file_path = './combined.txt'
print(f'Generating file {combined_file_path}')
with open(f'{combined_file_path}', mode='w', newline='') as file:
file.writelines(all_lines) (4)
print(f'File {combined_file_path} successfully generated')
1 | Primeiro, encontramos todos os arquivos de log customizado gerados na etapa anterior. |
2 | Então adicionamos o conteúdo de todos eles em uma mesma lista. |
3 | Com todo o histórico em memória podemos ordená-lo. |
4 | Por fim, salvamos o histórico combinado em um novo arquivo combined.txt . Este arquivo será a entrada da próxima etapa. |
Renderizando o vídeo
A próxima etapa é gerar nossa visualização utilizando o Gource.
É interessante que essa etapa seja executada em um computador com bastante disco, e de preferência, bem potente. A título de exemplo, a visualização de 5 minutos que geramos com base em um ano de histórico e 165 repositórios resultou num arquivo de 54GB. |
Os parâmetros utilizados vão variar bastante, a depender do tamanho da visualização, do que quisermos priorizar e demais características que queiramos atingir.
gource "combined.txt" -1920x1080 \
--seconds-per-day 0.4 \ (1)
--camera-mode track \
--multi-sampling \
--padding 1.1 \
--elasticity 0.005 \
--bloom-multiplier 1 --bloom-intensity 0.1 \
--stop-at-end \
--highlight-users \
--hide mouse,progress,filenames \
--file-idle-time 13 \
--max-files 0 \
--background-colour 000000 \
--start-date '2021-01-01 00:00:00' \
--title "Retrospectiva Hercules - 01/2021 a 12/2021" \
--date-format "%d/%m/%y" \
--font-size 18 \
--dir-name-depth 3 \
--logo "logo.jpg" \
--output-framerate 30 \ (2)
--file-font-size 4 \
--highlight-colour 0bc7ed \ (3)
--max-user-speed 500 \
--output-ppm-stream ./output.ppm (4)
1 | O parâmetro é mais importante talvez seja o seconds-per-day . É ele que determinará a duração do vídeo. |
2 | É importante se atentar ao framerate, pois ele influenciará a qualidade do vídeo. |
3 | É interessante destacar o nome dos usuários usando uma cor de fonte diferente do sistema de arquivos. Nesse caso, foi usado o azul. |
4 | A saída é gerada com o codec PPM, também conhecido como Portable Pixel Map |
Provavelmente serão necessárias várias iterações e ajustes até chegar a um resultado satisfatório. Essa é a parte divertida e exige bastante exploração dos inúmeros parâmetros de configuração do Gource.
O comando |
A última etapa é converter o vídeo para um formato mais fácil de trabalhar.
Convertendo o vídeo para .mp4
Para converter o vídeo para o formato .mp4
, a forma mais prática que encontrei foi utilizar uma imagem Docker do avconv
.
docker run -it \
-v .:/files \ (1)
-v .:/home/docker \
--rm=true -u="1000" \
jedimonkey/avconv avconv -y \
-r 30 \ (2)
-f image2pipe \
-vcodec ppm \
-i /files/output.ppm \
-b 32768k \
/files/output.mp4 (3)
1 | Precisamos mapear num volume o diretório que contém nosso arquivo .ppm |
2 | O framerate aqui deve ser o mesmo informado na geração do .ppm |
3 | Este será o arquivo de saída |
Se tudo der certo, teremos nosso arquivo finalizado.
Resultados
Abaixo, podemos ver a visualização gerada no final desse processo.
A grande vantagem de ter este processo automatizado, é que isso o torna repetível. Numa empresa grande como a Ambev Tech, com centenas de times e sistemas, outros times podem tirar proveito dessa automação.
Gostou desse post? Deixe um comentário. Vamos compartilhar experiências!