Ver código fonte

Implement `git log`

This is quite barebones for now.

Commit formatting is done in a separate file, as this is used by other
git commands, such as `show`.
Sam Atkins 1 ano atrás
pai
commit
98c33fb3cc

+ 177 - 0
packages/git/src/format.js

@@ -0,0 +1,177 @@
+/*
+ * Copyright (C) 2024  Puter Technologies Inc.
+ *
+ * This file is part of Puter's Git client.
+ *
+ * Puter's Git client is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+import { shorten_hash } from './git-helpers.js';
+
+export const commit_formatting_options = {
+    'abbrev-commit': {
+        description: 'Display commit hashes in abbreviated form.',
+        type: 'boolean',
+    },
+    'no-abbrev-commit': {
+        description: 'Always show full commit hashes.',
+        type: 'boolean',
+    },
+    'format': {
+        description: 'Format to use for commits.',
+        type: 'string',
+    },
+    'oneline': {
+        description: 'Shorthand for "--format=oneline --abbrev-commit".',
+        type: 'boolean',
+    },
+};
+
+/**
+ * Process command-line options related to commit formatting, and modify them in place.
+ * May throw if the options are in some way invalid.
+ * @param options Parsed command-line options, which will be modified in place.
+ */
+export const process_commit_formatting_options = (options) => {
+    if (options.oneline) {
+        options.format = 'oneline';
+        options['abbrev-commit'] = true;
+    }
+
+    options.short_hashes = (options['abbrev-commit'] === true) && (options['no-abbrev-commit'] !== true);
+    delete options['abbrev-commit'];
+    delete options['no-abbrev-commit'];
+
+    if (!options.format) {
+        options.format = 'medium';
+    }
+    if (!['oneline', 'short', 'medium', 'full', 'fuller', 'raw'].includes(options.format)) {
+        throw new Error(`Invalid --format format: ${options.format}`);
+    }
+}
+
+/**
+ * Format the given oid hash, followed by any refs that point to it
+ * @param oid
+ * @param short_hashes Whwther to shorten the hash
+ * @returns {String}
+ */
+export const format_oid = (oid, { short_hashes = false } = {}) => {
+    // TODO: List refs at this commit, after the hash
+    return short_hashes ? shorten_hash(oid) : oid;
+}
+
+/**
+ * Format the person's name and email as `${name} <${email}>`
+ * @param person
+ * @returns {`${string} <${string}>`}
+ */
+export const format_person = (person) => {
+    return `${person.name} <${person.email}>`;
+}
+
+/**
+ * Format a date
+ * @param date
+ * @param options
+ * @returns {string}
+ */
+export const format_date = (date, options = {}) => {
+    // TODO: This needs to obey date-format options, and should show the correct timezone not UTC
+    return new Date(date.timestamp * 1000).toUTCString();
+}
+
+/**
+ * Format the date, according to the "raw" display format.
+ * @param owner
+ * @returns {`${string} ${string}${string}${string}`}
+ */
+export const format_timestamp_and_offset = (owner) => {
+    // FIXME: The timezone offset is inverted.
+    //        Either this is correct here, or we should be inverting it when creating the commit -
+    //        Isomorphic git uses (new Date()).timezoneOffset() there, which returns -60 for BST, which is UTC+0100
+    const offset = -owner.timezoneOffset;
+    const offset_hours = Math.floor(offset / 60);
+    const offset_minutes = offset % 60;
+    const pad = (number) => `${Math.abs(number) < 10 ? '0' : ''}${Math.abs(number)}`;
+    return `${owner.timestamp} ${offset < 0 ? '-' : '+'}${pad(offset_hours)}${pad(offset_minutes)}`;
+}
+
+/**
+ * Produce a string representation of a commit.
+ * @param commit A CommitObject
+ * @param oid Commit hash
+ * @param options Options returned by parsing the command arguments in `commit_formatting_options`
+ * @returns {string}
+ */
+export const format_commit = (commit, oid, options = {}) => {
+    const title_line = () => commit.message.split('\n')[0];
+
+    switch (options.format || 'medium') {
+        // TODO: Other formats
+        case 'oneline':
+            return `${format_oid(oid, options)} ${title_line()}`;
+        case 'short': {
+            let s = '';
+            s += `commit ${format_oid(oid, options)}\n`;
+            s += `Author: ${format_person(commit.author)}\n`;
+            s += '\n';
+            s += title_line();
+            return s;
+        }
+        case 'medium': {
+            let s = '';
+            s += `commit ${format_oid(oid, options)}\n`;
+            s += `Author: ${format_person(commit.author)}\n`;
+            s += `Date:   ${format_date(commit.author)}\n`;
+            s += '\n';
+            s += commit.message;
+            return s;
+        }
+        case 'full': {
+            let s = '';
+            s += `commit ${format_oid(oid, options)}\n`;
+            s += `Author: ${format_person(commit.author)}\n`;
+            s += `Commit: ${format_person(commit.committer)}\n`;
+            s += '\n';
+            s += commit.message;
+            return s;
+        }
+        case 'fuller': {
+            let s = '';
+            s += `commit ${format_oid(oid, options)}\n`;
+            s += `Author:     ${format_person(commit.author)}\n`;
+            s += `AuthorDate: ${format_date(commit.author)}\n`;
+            s += `Commit:     ${format_person(commit.committer)}\n`;
+            s += `CommitDate: ${format_date(commit.committer)}\n`;
+            s += '\n';
+            s += commit.message;
+            return s;
+        }
+        case 'raw': {
+            let s = '';
+            s += `commit ${oid}\n`;
+            s += `tree ${commit.tree}\n`;
+            if (commit.parent[0])
+                s += `parent ${commit.parent[0]}\n`;
+            s += `author ${format_person(commit.author)} ${format_timestamp_and_offset(commit.author)}\n`;
+            s += `committer ${format_person(commit.committer)} ${format_timestamp_and_offset(commit.committer)}\n`;
+            s += '\n';
+            s += commit.message;
+            return s;
+        }
+        default: {
+            throw new Error(`Invalid --format format: ${options.format}`);
+        }
+    }
+}

+ 2 - 0
packages/git/src/subcommands/__exports__.js

@@ -21,6 +21,7 @@ import module_add from './add.js'
 import module_commit from './commit.js'
 import module_help from './help.js'
 import module_init from './init.js'
+import module_log from './log.js'
 import module_status from './status.js'
 import module_version from './version.js'
 
@@ -29,6 +30,7 @@ export default {
     "commit": module_commit,
     "help": module_help,
     "init": module_init,
+    "log": module_log,
     "status": module_status,
     "version": module_version,
 };

+ 64 - 0
packages/git/src/subcommands/log.js

@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2024  Puter Technologies Inc.
+ *
+ * This file is part of Puter's Git client.
+ *
+ * Puter's Git client is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+import git from 'isomorphic-git';
+import { find_repo_root } from '../git-helpers.js';
+import { commit_formatting_options, format_commit, process_commit_formatting_options } from '../format.js';
+
+export default {
+    name: 'log',
+    usage: 'git log [<formatting-option>...] [--max-count <n>] <revision>',
+    description: 'Show commit logs, starting at the given revision.',
+    args: {
+        allowPositionals: false,
+        options: {
+            ...commit_formatting_options,
+            'max-count': {
+                description: 'Maximum number of commits to output.',
+                type: 'string',
+                short: 'n',
+            },
+        },
+    },
+    execute: async (ctx) => {
+        const { io, fs, env, args } = ctx;
+        const { stdout, stderr } = io;
+        const { options, positionals } = args;
+
+        process_commit_formatting_options(options);
+
+        // TODO: Log of a specific file
+        // TODO: Log of a specific branch
+        // TODO: Log of a specific commit
+
+        const depth = Number(options['max-count']) || undefined;
+
+        const { repository_dir, git_dir } = await find_repo_root(fs, env.PWD);
+
+        const log = await git.log({
+            fs,
+            dir: repository_dir,
+            gitdir: git_dir,
+            depth,
+        });
+
+        for (const commit of log) {
+            stdout(format_commit(commit.commit, commit.oid, options));
+        }
+    }
+}