readme file is now rendering in repo. can now view files and edit them. can switch between branches with dropdown menu
This commit is contained in:
@@ -1 +1 @@
|
|||||||
9bb44448374d994d9ff09055d42d623adc9387b6
|
c6727942d6cb4a7370012089badb7579d6f0ed26
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ref: refs/heads/main
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[core]
|
||||||
|
repositoryformatversion = 0
|
||||||
|
filemode = true
|
||||||
|
bare = true
|
||||||
|
ignorecase = true
|
||||||
|
precomposeunicode = true
|
||||||
|
[http]
|
||||||
|
receivepack = true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Unnamed repository; edit this file 'description' to name the repository.
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# An example hook script to check the commit log message taken by
|
||||||
|
# applypatch from an e-mail message.
|
||||||
|
#
|
||||||
|
# The hook should exit with non-zero status after issuing an
|
||||||
|
# appropriate message if it wants to stop the commit. The hook is
|
||||||
|
# allowed to edit the commit message file.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "applypatch-msg".
|
||||||
|
|
||||||
|
. git-sh-setup
|
||||||
|
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
|
||||||
|
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
|
||||||
|
:
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# An example hook script to check the commit log message.
|
||||||
|
# Called by "git commit" with one argument, the name of the file
|
||||||
|
# that has the commit message. The hook should exit with non-zero
|
||||||
|
# status after issuing an appropriate message if it wants to stop the
|
||||||
|
# commit. The hook is allowed to edit the commit message file.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "commit-msg".
|
||||||
|
|
||||||
|
# Uncomment the below to add a Signed-off-by line to the message.
|
||||||
|
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
|
||||||
|
# hook is more suited to it.
|
||||||
|
#
|
||||||
|
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
|
||||||
|
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
|
||||||
|
|
||||||
|
# This example catches duplicate Signed-off-by lines and messages that
|
||||||
|
# would confuse 'git am'.
|
||||||
|
|
||||||
|
ret=0
|
||||||
|
|
||||||
|
test "" = "$(grep '^Signed-off-by: ' "$1" |
|
||||||
|
sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
|
||||||
|
echo >&2 Duplicate Signed-off-by lines.
|
||||||
|
ret=1
|
||||||
|
}
|
||||||
|
|
||||||
|
comment_re="$(
|
||||||
|
{
|
||||||
|
git config --get-regexp "^core\.comment(char|string)\$" ||
|
||||||
|
echo '#'
|
||||||
|
} | sed -n -e '
|
||||||
|
${
|
||||||
|
s/^[^ ]* //
|
||||||
|
s|[][*./\]|\\&|g
|
||||||
|
s/^auto$/[#;@!$%^&|:]/
|
||||||
|
p
|
||||||
|
}'
|
||||||
|
)"
|
||||||
|
scissors_line="^${comment_re} -\{8,\} >8 -\{8,\}\$"
|
||||||
|
comment_line="^${comment_re}.*"
|
||||||
|
blank_line='^[ ]*$'
|
||||||
|
# Disallow lines starting with "diff -" or "Index: " in the body of the
|
||||||
|
# message. Stop looking if we see a scissors line.
|
||||||
|
line="$(sed -n -e "
|
||||||
|
# Skip comments and blank lines at the start of the file.
|
||||||
|
/${scissors_line}/q
|
||||||
|
/${comment_line}/d
|
||||||
|
/${blank_line}/d
|
||||||
|
# The first paragraph will become the subject header so
|
||||||
|
# does not need to be checked.
|
||||||
|
: subject
|
||||||
|
n
|
||||||
|
/${scissors_line}/q
|
||||||
|
/${blank_line}/!b subject
|
||||||
|
# Check the body of the message for problematic
|
||||||
|
# prefixes.
|
||||||
|
: body
|
||||||
|
n
|
||||||
|
/${scissors_line}/q
|
||||||
|
/${comment_line}/b body
|
||||||
|
/^diff -/{p;q;}
|
||||||
|
/^Index: /{p;q;}
|
||||||
|
b body
|
||||||
|
" "$1")"
|
||||||
|
if test -n "$line"
|
||||||
|
then
|
||||||
|
echo >&2 "Message contains a diff that will confuse 'git am'."
|
||||||
|
echo >&2 "To fix this indent the diff."
|
||||||
|
ret=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $ret
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/perl
|
||||||
|
|
||||||
|
use strict;
|
||||||
|
use warnings;
|
||||||
|
use IPC::Open2;
|
||||||
|
|
||||||
|
# An example hook script to integrate Watchman
|
||||||
|
# (https://facebook.github.io/watchman/) with git to speed up detecting
|
||||||
|
# new and modified files.
|
||||||
|
#
|
||||||
|
# The hook is passed a version (currently 2) and last update token
|
||||||
|
# formatted as a string and outputs to stdout a new update token and
|
||||||
|
# all files that have been modified since the update token. Paths must
|
||||||
|
# be relative to the root of the working tree and separated by a single NUL.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "query-watchman" and set
|
||||||
|
# 'git config core.fsmonitor .git/hooks/query-watchman'
|
||||||
|
#
|
||||||
|
my ($version, $last_update_token) = @ARGV;
|
||||||
|
|
||||||
|
# Uncomment for debugging
|
||||||
|
# print STDERR "$0 $version $last_update_token\n";
|
||||||
|
|
||||||
|
# Check the hook interface version
|
||||||
|
if ($version ne 2) {
|
||||||
|
die "Unsupported query-fsmonitor hook version '$version'.\n" .
|
||||||
|
"Falling back to scanning...\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
my $git_work_tree = get_working_dir();
|
||||||
|
|
||||||
|
my $json_pkg;
|
||||||
|
eval {
|
||||||
|
require JSON::XS;
|
||||||
|
$json_pkg = "JSON::XS";
|
||||||
|
1;
|
||||||
|
} or do {
|
||||||
|
require JSON::PP;
|
||||||
|
$json_pkg = "JSON::PP";
|
||||||
|
};
|
||||||
|
|
||||||
|
launch_watchman();
|
||||||
|
|
||||||
|
sub launch_watchman {
|
||||||
|
my $o = watchman_query();
|
||||||
|
if (is_work_tree_watched($o)) {
|
||||||
|
output_result($o->{clock}, @{$o->{files}});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sub output_result {
|
||||||
|
my ($clockid, @files) = @_;
|
||||||
|
|
||||||
|
# Uncomment for debugging watchman output
|
||||||
|
# open (my $fh, ">", ".git/watchman-output.out");
|
||||||
|
# binmode $fh, ":utf8";
|
||||||
|
# print $fh "$clockid\n@files\n";
|
||||||
|
# close $fh;
|
||||||
|
|
||||||
|
binmode STDOUT, ":utf8";
|
||||||
|
print $clockid;
|
||||||
|
print "\0";
|
||||||
|
local $, = "\0";
|
||||||
|
print @files;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub watchman_clock {
|
||||||
|
my $response = qx/watchman clock "$git_work_tree"/;
|
||||||
|
die "Failed to get clock id on '$git_work_tree'.\n" .
|
||||||
|
"Falling back to scanning...\n" if $? != 0;
|
||||||
|
|
||||||
|
return $json_pkg->new->utf8->decode($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub watchman_query {
|
||||||
|
my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
|
||||||
|
or die "open2() failed: $!\n" .
|
||||||
|
"Falling back to scanning...\n";
|
||||||
|
|
||||||
|
# In the query expression below we're asking for names of files that
|
||||||
|
# changed since $last_update_token but not from the .git folder.
|
||||||
|
#
|
||||||
|
# To accomplish this, we're using the "since" generator to use the
|
||||||
|
# recency index to select candidate nodes and "fields" to limit the
|
||||||
|
# output to file names only. Then we're using the "expression" term to
|
||||||
|
# further constrain the results.
|
||||||
|
my $last_update_line = "";
|
||||||
|
if (substr($last_update_token, 0, 1) eq "c") {
|
||||||
|
$last_update_token = "\"$last_update_token\"";
|
||||||
|
$last_update_line = qq[\n"since": $last_update_token,];
|
||||||
|
}
|
||||||
|
my $query = <<" END";
|
||||||
|
["query", "$git_work_tree", {$last_update_line
|
||||||
|
"fields": ["name"],
|
||||||
|
"expression": ["not", ["dirname", ".git"]]
|
||||||
|
}]
|
||||||
|
END
|
||||||
|
|
||||||
|
# Uncomment for debugging the watchman query
|
||||||
|
# open (my $fh, ">", ".git/watchman-query.json");
|
||||||
|
# print $fh $query;
|
||||||
|
# close $fh;
|
||||||
|
|
||||||
|
print CHLD_IN $query;
|
||||||
|
close CHLD_IN;
|
||||||
|
my $response = do {local $/; <CHLD_OUT>};
|
||||||
|
|
||||||
|
# Uncomment for debugging the watch response
|
||||||
|
# open ($fh, ">", ".git/watchman-response.json");
|
||||||
|
# print $fh $response;
|
||||||
|
# close $fh;
|
||||||
|
|
||||||
|
die "Watchman: command returned no output.\n" .
|
||||||
|
"Falling back to scanning...\n" if $response eq "";
|
||||||
|
die "Watchman: command returned invalid output: $response\n" .
|
||||||
|
"Falling back to scanning...\n" unless $response =~ /^\{/;
|
||||||
|
|
||||||
|
return $json_pkg->new->utf8->decode($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub is_work_tree_watched {
|
||||||
|
my ($output) = @_;
|
||||||
|
my $error = $output->{error};
|
||||||
|
if ($error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
|
||||||
|
my $response = qx/watchman watch "$git_work_tree"/;
|
||||||
|
die "Failed to make watchman watch '$git_work_tree'.\n" .
|
||||||
|
"Falling back to scanning...\n" if $? != 0;
|
||||||
|
$output = $json_pkg->new->utf8->decode($response);
|
||||||
|
$error = $output->{error};
|
||||||
|
die "Watchman: $error.\n" .
|
||||||
|
"Falling back to scanning...\n" if $error;
|
||||||
|
|
||||||
|
# Uncomment for debugging watchman output
|
||||||
|
# open (my $fh, ">", ".git/watchman-output.out");
|
||||||
|
# close $fh;
|
||||||
|
|
||||||
|
# Watchman will always return all files on the first query so
|
||||||
|
# return the fast "everything is dirty" flag to git and do the
|
||||||
|
# Watchman query just to get it over with now so we won't pay
|
||||||
|
# the cost in git to look up each individual file.
|
||||||
|
my $o = watchman_clock();
|
||||||
|
$error = $o->{error};
|
||||||
|
|
||||||
|
die "Watchman: $error.\n" .
|
||||||
|
"Falling back to scanning...\n" if $error;
|
||||||
|
|
||||||
|
output_result($o->{clock}, ("/"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
die "Watchman: $error.\n" .
|
||||||
|
"Falling back to scanning...\n" if $error;
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub get_working_dir {
|
||||||
|
my $working_dir;
|
||||||
|
if ($^O =~ 'msys' || $^O =~ 'cygwin') {
|
||||||
|
$working_dir = Win32::GetCwd();
|
||||||
|
$working_dir =~ tr/\\/\//;
|
||||||
|
} else {
|
||||||
|
require Cwd;
|
||||||
|
$working_dir = Cwd::cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $working_dir;
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# An example hook script to prepare a packed repository for use over
|
||||||
|
# dumb transports.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "post-update".
|
||||||
|
|
||||||
|
exec git update-server-info
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# An example hook script to verify what is about to be committed
|
||||||
|
# by applypatch from an e-mail message.
|
||||||
|
#
|
||||||
|
# The hook should exit with non-zero status after issuing an
|
||||||
|
# appropriate message if it wants to stop the commit.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "pre-applypatch".
|
||||||
|
|
||||||
|
. git-sh-setup
|
||||||
|
precommit="$(git rev-parse --git-path hooks/pre-commit)"
|
||||||
|
test -x "$precommit" && exec "$precommit" ${1+"$@"}
|
||||||
|
:
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# An example hook script to verify what is about to be committed.
|
||||||
|
# Called by "git commit" with no arguments. The hook should
|
||||||
|
# exit with non-zero status after issuing an appropriate message if
|
||||||
|
# it wants to stop the commit.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "pre-commit".
|
||||||
|
|
||||||
|
if git rev-parse --verify HEAD >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
against=HEAD
|
||||||
|
else
|
||||||
|
# Initial commit: diff against an empty tree object
|
||||||
|
against=$(git hash-object -t tree /dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If you want to allow non-ASCII filenames set this variable to true.
|
||||||
|
allownonascii=$(git config --type=bool hooks.allownonascii)
|
||||||
|
|
||||||
|
# Redirect output to stderr.
|
||||||
|
exec 1>&2
|
||||||
|
|
||||||
|
# Cross platform projects tend to avoid non-ASCII filenames; prevent
|
||||||
|
# them from being added to the repository. We exploit the fact that the
|
||||||
|
# printable range starts at the space character and ends with tilde.
|
||||||
|
if [ "$allownonascii" != "true" ] &&
|
||||||
|
# Note that the use of brackets around a tr range is ok here, (it's
|
||||||
|
# even required, for portability to Solaris 10's /usr/bin/tr), since
|
||||||
|
# the square bracket bytes happen to fall in the designated range.
|
||||||
|
test $(git diff-index --cached --name-only --diff-filter=A -z $against |
|
||||||
|
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
|
||||||
|
then
|
||||||
|
cat <<\EOF
|
||||||
|
Error: Attempt to add a non-ASCII file name.
|
||||||
|
|
||||||
|
This can cause problems if you want to work with people on other platforms.
|
||||||
|
|
||||||
|
To be portable it is advisable to rename the file.
|
||||||
|
|
||||||
|
If you know what you are doing you can disable this check using:
|
||||||
|
|
||||||
|
git config hooks.allownonascii true
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If there are whitespace errors, print the offending file names and fail.
|
||||||
|
exec git diff-index --check --cached $against --
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# An example hook script to verify what is about to be committed.
|
||||||
|
# Called by "git merge" with no arguments. The hook should
|
||||||
|
# exit with non-zero status after issuing an appropriate message to
|
||||||
|
# stderr if it wants to stop the merge commit.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "pre-merge-commit".
|
||||||
|
|
||||||
|
. git-sh-setup
|
||||||
|
test -x "$GIT_DIR/hooks/pre-commit" &&
|
||||||
|
exec "$GIT_DIR/hooks/pre-commit"
|
||||||
|
:
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# An example hook script to verify what is about to be pushed. Called by "git
|
||||||
|
# push" after it has checked the remote status, but before anything has been
|
||||||
|
# pushed. If this script exits with a non-zero status nothing will be pushed.
|
||||||
|
#
|
||||||
|
# This hook is called with the following parameters:
|
||||||
|
#
|
||||||
|
# $1 -- Name of the remote to which the push is being done
|
||||||
|
# $2 -- URL to which the push is being done
|
||||||
|
#
|
||||||
|
# If pushing without using a named remote those arguments will be equal.
|
||||||
|
#
|
||||||
|
# Information about the commits which are being pushed is supplied as lines to
|
||||||
|
# the standard input in the form:
|
||||||
|
#
|
||||||
|
# <local ref> <local oid> <remote ref> <remote oid>
|
||||||
|
#
|
||||||
|
# This sample shows how to prevent push of commits where the log message starts
|
||||||
|
# with "WIP" (work in progress).
|
||||||
|
|
||||||
|
remote="$1"
|
||||||
|
url="$2"
|
||||||
|
|
||||||
|
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
|
||||||
|
|
||||||
|
while read local_ref local_oid remote_ref remote_oid
|
||||||
|
do
|
||||||
|
if test "$local_oid" = "$zero"
|
||||||
|
then
|
||||||
|
# Handle delete
|
||||||
|
:
|
||||||
|
else
|
||||||
|
if test "$remote_oid" = "$zero"
|
||||||
|
then
|
||||||
|
# New branch, examine all commits
|
||||||
|
range="$local_oid"
|
||||||
|
else
|
||||||
|
# Update to existing branch, examine new commits
|
||||||
|
range="$remote_oid..$local_oid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for WIP commit
|
||||||
|
commit=$(git rev-list -n 1 --grep '^WIP' "$range")
|
||||||
|
if test -n "$commit"
|
||||||
|
then
|
||||||
|
echo >&2 "Found WIP commit in $local_ref, not pushing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# Copyright (c) 2006, 2008 Junio C Hamano
|
||||||
|
#
|
||||||
|
# The "pre-rebase" hook is run just before "git rebase" starts doing
|
||||||
|
# its job, and can prevent the command from running by exiting with
|
||||||
|
# non-zero status.
|
||||||
|
#
|
||||||
|
# The hook is called with the following parameters:
|
||||||
|
#
|
||||||
|
# $1 -- the upstream the series was forked from.
|
||||||
|
# $2 -- the branch being rebased (or empty when rebasing the current branch).
|
||||||
|
#
|
||||||
|
# This sample shows how to prevent topic branches that are already
|
||||||
|
# merged to 'next' branch from getting rebased, because allowing it
|
||||||
|
# would result in rebasing already published history.
|
||||||
|
|
||||||
|
publish=next
|
||||||
|
basebranch="$1"
|
||||||
|
if test "$#" = 2
|
||||||
|
then
|
||||||
|
topic="refs/heads/$2"
|
||||||
|
else
|
||||||
|
topic=`git symbolic-ref HEAD` ||
|
||||||
|
exit 0 ;# we do not interrupt rebasing detached HEAD
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$topic" in
|
||||||
|
refs/heads/??/*)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exit 0 ;# we do not interrupt others.
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Now we are dealing with a topic branch being rebased
|
||||||
|
# on top of master. Is it OK to rebase it?
|
||||||
|
|
||||||
|
# Does the topic really exist?
|
||||||
|
git show-ref -q "$topic" || {
|
||||||
|
echo >&2 "No such branch $topic"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Is topic fully merged to master?
|
||||||
|
not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
|
||||||
|
if test -z "$not_in_master"
|
||||||
|
then
|
||||||
|
echo >&2 "$topic is fully merged to master; better remove it."
|
||||||
|
exit 1 ;# we could allow it, but there is no point.
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Is topic ever merged to next? If so you should not be rebasing it.
|
||||||
|
only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
|
||||||
|
only_next_2=`git rev-list ^master ${publish} | sort`
|
||||||
|
if test "$only_next_1" = "$only_next_2"
|
||||||
|
then
|
||||||
|
not_in_topic=`git rev-list "^$topic" master`
|
||||||
|
if test -z "$not_in_topic"
|
||||||
|
then
|
||||||
|
echo >&2 "$topic is already up to date with master"
|
||||||
|
exit 1 ;# we could allow it, but there is no point.
|
||||||
|
else
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
|
||||||
|
/usr/bin/perl -e '
|
||||||
|
my $topic = $ARGV[0];
|
||||||
|
my $msg = "* $topic has commits already merged to public branch:\n";
|
||||||
|
my (%not_in_next) = map {
|
||||||
|
/^([0-9a-f]+) /;
|
||||||
|
($1 => 1);
|
||||||
|
} split(/\n/, $ARGV[1]);
|
||||||
|
for my $elem (map {
|
||||||
|
/^([0-9a-f]+) (.*)$/;
|
||||||
|
[$1 => $2];
|
||||||
|
} split(/\n/, $ARGV[2])) {
|
||||||
|
if (!exists $not_in_next{$elem->[0]}) {
|
||||||
|
if ($msg) {
|
||||||
|
print STDERR $msg;
|
||||||
|
undef $msg;
|
||||||
|
}
|
||||||
|
print STDERR " $elem->[1]\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$topic" "$not_in_next" "$not_in_master"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
<<\DOC_END
|
||||||
|
|
||||||
|
This sample hook safeguards topic branches that have been
|
||||||
|
published from being rewound.
|
||||||
|
|
||||||
|
The workflow assumed here is:
|
||||||
|
|
||||||
|
* Once a topic branch forks from "master", "master" is never
|
||||||
|
merged into it again (either directly or indirectly).
|
||||||
|
|
||||||
|
* Once a topic branch is fully cooked and merged into "master",
|
||||||
|
it is deleted. If you need to build on top of it to correct
|
||||||
|
earlier mistakes, a new topic branch is created by forking at
|
||||||
|
the tip of the "master". This is not strictly necessary, but
|
||||||
|
it makes it easier to keep your history simple.
|
||||||
|
|
||||||
|
* Whenever you need to test or publish your changes to topic
|
||||||
|
branches, merge them into "next" branch.
|
||||||
|
|
||||||
|
The script, being an example, hardcodes the publish branch name
|
||||||
|
to be "next", but it is trivial to make it configurable via
|
||||||
|
$GIT_DIR/config mechanism.
|
||||||
|
|
||||||
|
With this workflow, you would want to know:
|
||||||
|
|
||||||
|
(1) ... if a topic branch has ever been merged to "next". Young
|
||||||
|
topic branches can have stupid mistakes you would rather
|
||||||
|
clean up before publishing, and things that have not been
|
||||||
|
merged into other branches can be easily rebased without
|
||||||
|
affecting other people. But once it is published, you would
|
||||||
|
not want to rewind it.
|
||||||
|
|
||||||
|
(2) ... if a topic branch has been fully merged to "master".
|
||||||
|
Then you can delete it. More importantly, you should not
|
||||||
|
build on top of it -- other people may already want to
|
||||||
|
change things related to the topic as patches against your
|
||||||
|
"master", so if you need further changes, it is better to
|
||||||
|
fork the topic (perhaps with the same name) afresh from the
|
||||||
|
tip of "master".
|
||||||
|
|
||||||
|
Let's look at this example:
|
||||||
|
|
||||||
|
o---o---o---o---o---o---o---o---o---o "next"
|
||||||
|
/ / / /
|
||||||
|
/ a---a---b A / /
|
||||||
|
/ / / /
|
||||||
|
/ / c---c---c---c B /
|
||||||
|
/ / / \ /
|
||||||
|
/ / / b---b C \ /
|
||||||
|
/ / / / \ /
|
||||||
|
---o---o---o---o---o---o---o---o---o---o---o "master"
|
||||||
|
|
||||||
|
|
||||||
|
A, B and C are topic branches.
|
||||||
|
|
||||||
|
* A has one fix since it was merged up to "next".
|
||||||
|
|
||||||
|
* B has finished. It has been fully merged up to "master" and "next",
|
||||||
|
and is ready to be deleted.
|
||||||
|
|
||||||
|
* C has not merged to "next" at all.
|
||||||
|
|
||||||
|
We would want to allow C to be rebased, refuse A, and encourage
|
||||||
|
B to be deleted.
|
||||||
|
|
||||||
|
To compute (1):
|
||||||
|
|
||||||
|
git rev-list ^master ^topic next
|
||||||
|
git rev-list ^master next
|
||||||
|
|
||||||
|
if these match, topic has not merged in next at all.
|
||||||
|
|
||||||
|
To compute (2):
|
||||||
|
|
||||||
|
git rev-list master..topic
|
||||||
|
|
||||||
|
if this is empty, it is fully merged to "master".
|
||||||
|
|
||||||
|
DOC_END
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# An example hook script to make use of push options.
|
||||||
|
# The example simply echoes all push options that start with 'echoback='
|
||||||
|
# and rejects all pushes when the "reject" push option is used.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "pre-receive".
|
||||||
|
|
||||||
|
if test -n "$GIT_PUSH_OPTION_COUNT"
|
||||||
|
then
|
||||||
|
i=0
|
||||||
|
while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
|
||||||
|
do
|
||||||
|
eval "value=\$GIT_PUSH_OPTION_$i"
|
||||||
|
case "$value" in
|
||||||
|
echoback=*)
|
||||||
|
echo "echo from the pre-receive-hook: ${value#*=}" >&2
|
||||||
|
;;
|
||||||
|
reject)
|
||||||
|
exit 1
|
||||||
|
esac
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
fi
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# An example hook script to prepare the commit log message.
|
||||||
|
# Called by "git commit" with the name of the file that has the
|
||||||
|
# commit message, followed by the description of the commit
|
||||||
|
# message's source. The hook's purpose is to edit the commit
|
||||||
|
# message file. If the hook fails with a non-zero status,
|
||||||
|
# the commit is aborted.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "prepare-commit-msg".
|
||||||
|
|
||||||
|
# This hook includes three examples. The first one removes the
|
||||||
|
# "# Please enter the commit message..." help message.
|
||||||
|
#
|
||||||
|
# The second includes the output of "git diff --name-status -r"
|
||||||
|
# into the message, just before the "git status" output. It is
|
||||||
|
# commented because it doesn't cope with --amend or with squashed
|
||||||
|
# commits.
|
||||||
|
#
|
||||||
|
# The third example adds a Signed-off-by line to the message, that can
|
||||||
|
# still be edited. This is rarely a good idea.
|
||||||
|
|
||||||
|
COMMIT_MSG_FILE=$1
|
||||||
|
COMMIT_SOURCE=$2
|
||||||
|
SHA1=$3
|
||||||
|
|
||||||
|
/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
|
||||||
|
|
||||||
|
# case "$COMMIT_SOURCE,$SHA1" in
|
||||||
|
# ,|template,)
|
||||||
|
# /usr/bin/perl -i.bak -pe '
|
||||||
|
# print "\n" . `git diff --cached --name-status -r`
|
||||||
|
# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
|
||||||
|
# *) ;;
|
||||||
|
# esac
|
||||||
|
|
||||||
|
# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
|
||||||
|
# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
|
||||||
|
# if test -z "$COMMIT_SOURCE"
|
||||||
|
# then
|
||||||
|
# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
|
||||||
|
# fi
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# An example hook script to update a checked-out tree on a git push.
|
||||||
|
#
|
||||||
|
# This hook is invoked by git-receive-pack(1) when it reacts to git
|
||||||
|
# push and updates reference(s) in its repository, and when the push
|
||||||
|
# tries to update the branch that is currently checked out and the
|
||||||
|
# receive.denyCurrentBranch configuration variable is set to
|
||||||
|
# updateInstead.
|
||||||
|
#
|
||||||
|
# By default, such a push is refused if the working tree and the index
|
||||||
|
# of the remote repository has any difference from the currently
|
||||||
|
# checked out commit; when both the working tree and the index match
|
||||||
|
# the current commit, they are updated to match the newly pushed tip
|
||||||
|
# of the branch. This hook is to be used to override the default
|
||||||
|
# behaviour; however the code below reimplements the default behaviour
|
||||||
|
# as a starting point for convenient modification.
|
||||||
|
#
|
||||||
|
# The hook receives the commit with which the tip of the current
|
||||||
|
# branch is going to be updated:
|
||||||
|
commit=$1
|
||||||
|
|
||||||
|
# It can exit with a non-zero status to refuse the push (when it does
|
||||||
|
# so, it must not modify the index or the working tree).
|
||||||
|
die () {
|
||||||
|
echo >&2 "$*"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Or it can make any necessary changes to the working tree and to the
|
||||||
|
# index to bring them to the desired state when the tip of the current
|
||||||
|
# branch is updated to the new commit, and exit with a zero status.
|
||||||
|
#
|
||||||
|
# For example, the hook can simply run git read-tree -u -m HEAD "$1"
|
||||||
|
# in order to emulate git fetch that is run in the reverse direction
|
||||||
|
# with git push, as the two-tree form of git read-tree -u -m is
|
||||||
|
# essentially the same as git switch or git checkout that switches
|
||||||
|
# branches while keeping the local changes in the working tree that do
|
||||||
|
# not interfere with the difference between the branches.
|
||||||
|
|
||||||
|
# The below is a more-or-less exact translation to shell of the C code
|
||||||
|
# for the default behaviour for git's push-to-checkout hook defined in
|
||||||
|
# the push_to_deploy() function in builtin/receive-pack.c.
|
||||||
|
#
|
||||||
|
# Note that the hook will be executed from the repository directory,
|
||||||
|
# not from the working tree, so if you want to perform operations on
|
||||||
|
# the working tree, you will have to adapt your code accordingly, e.g.
|
||||||
|
# by adding "cd .." or using relative paths.
|
||||||
|
|
||||||
|
if ! git update-index -q --ignore-submodules --refresh
|
||||||
|
then
|
||||||
|
die "Up-to-date check failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git diff-files --quiet --ignore-submodules --
|
||||||
|
then
|
||||||
|
die "Working directory has unstaged changes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This is a rough translation of:
|
||||||
|
#
|
||||||
|
# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
|
||||||
|
if git cat-file -e HEAD 2>/dev/null
|
||||||
|
then
|
||||||
|
head=HEAD
|
||||||
|
else
|
||||||
|
head=$(git hash-object -t tree --stdin </dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git diff-index --quiet --cached --ignore-submodules $head --
|
||||||
|
then
|
||||||
|
die "Working directory has staged changes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git read-tree -u -m "$commit"
|
||||||
|
then
|
||||||
|
die "Could not update working tree to new HEAD"
|
||||||
|
fi
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# An example hook script to validate a patch (and/or patch series) before
|
||||||
|
# sending it via email.
|
||||||
|
#
|
||||||
|
# The hook should exit with non-zero status after issuing an appropriate
|
||||||
|
# message if it wants to prevent the email(s) from being sent.
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "sendemail-validate".
|
||||||
|
#
|
||||||
|
# By default, it will only check that the patch(es) can be applied on top of
|
||||||
|
# the default upstream branch without conflicts in a secondary worktree. After
|
||||||
|
# validation (successful or not) of the last patch of a series, the worktree
|
||||||
|
# will be deleted.
|
||||||
|
#
|
||||||
|
# The following config variables can be set to change the default remote and
|
||||||
|
# remote ref that are used to apply the patches against:
|
||||||
|
#
|
||||||
|
# sendemail.validateRemote (default: origin)
|
||||||
|
# sendemail.validateRemoteRef (default: HEAD)
|
||||||
|
#
|
||||||
|
# Replace the TODO placeholders with appropriate checks according to your
|
||||||
|
# needs.
|
||||||
|
|
||||||
|
validate_cover_letter () {
|
||||||
|
file="$1"
|
||||||
|
# TODO: Replace with appropriate checks (e.g. spell checking).
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_patch () {
|
||||||
|
file="$1"
|
||||||
|
# Ensure that the patch applies without conflicts.
|
||||||
|
git am -3 "$file" || return
|
||||||
|
# TODO: Replace with appropriate checks for this patch
|
||||||
|
# (e.g. checkpatch.pl).
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_series () {
|
||||||
|
# TODO: Replace with appropriate checks for the whole series
|
||||||
|
# (e.g. quick build, coding style checks, etc.).
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
# main -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1
|
||||||
|
then
|
||||||
|
remote=$(git config --default origin --get sendemail.validateRemote) &&
|
||||||
|
ref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&
|
||||||
|
worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&
|
||||||
|
git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" &&
|
||||||
|
git config --replace-all sendemail.validateWorktree "$worktree"
|
||||||
|
else
|
||||||
|
worktree=$(git config --get sendemail.validateWorktree)
|
||||||
|
fi || {
|
||||||
|
echo "sendemail-validate: error: failed to prepare worktree" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
unset GIT_DIR GIT_WORK_TREE
|
||||||
|
cd "$worktree" &&
|
||||||
|
|
||||||
|
if grep -q "^diff --git " "$1"
|
||||||
|
then
|
||||||
|
validate_patch "$1"
|
||||||
|
else
|
||||||
|
validate_cover_letter "$1"
|
||||||
|
fi &&
|
||||||
|
|
||||||
|
if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL"
|
||||||
|
then
|
||||||
|
git config --unset-all sendemail.validateWorktree &&
|
||||||
|
trap 'git worktree remove -ff "$worktree"' EXIT &&
|
||||||
|
validate_series
|
||||||
|
fi
|
||||||
Executable
+128
@@ -0,0 +1,128 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# An example hook script to block unannotated tags from entering.
|
||||||
|
# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
|
||||||
|
#
|
||||||
|
# To enable this hook, rename this file to "update".
|
||||||
|
#
|
||||||
|
# Config
|
||||||
|
# ------
|
||||||
|
# hooks.allowunannotated
|
||||||
|
# This boolean sets whether unannotated tags will be allowed into the
|
||||||
|
# repository. By default they won't be.
|
||||||
|
# hooks.allowdeletetag
|
||||||
|
# This boolean sets whether deleting tags will be allowed in the
|
||||||
|
# repository. By default they won't be.
|
||||||
|
# hooks.allowmodifytag
|
||||||
|
# This boolean sets whether a tag may be modified after creation. By default
|
||||||
|
# it won't be.
|
||||||
|
# hooks.allowdeletebranch
|
||||||
|
# This boolean sets whether deleting branches will be allowed in the
|
||||||
|
# repository. By default they won't be.
|
||||||
|
# hooks.denycreatebranch
|
||||||
|
# This boolean sets whether remotely creating branches will be denied
|
||||||
|
# in the repository. By default this is allowed.
|
||||||
|
#
|
||||||
|
|
||||||
|
# --- Command line
|
||||||
|
refname="$1"
|
||||||
|
oldrev="$2"
|
||||||
|
newrev="$3"
|
||||||
|
|
||||||
|
# --- Safety check
|
||||||
|
if [ -z "$GIT_DIR" ]; then
|
||||||
|
echo "Don't run this script from the command line." >&2
|
||||||
|
echo " (if you want, you could supply GIT_DIR then run" >&2
|
||||||
|
echo " $0 <ref> <oldrev> <newrev>)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
|
||||||
|
echo "usage: $0 <ref> <oldrev> <newrev>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Config
|
||||||
|
allowunannotated=$(git config --type=bool hooks.allowunannotated)
|
||||||
|
allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
|
||||||
|
denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
|
||||||
|
allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
|
||||||
|
allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
|
||||||
|
|
||||||
|
# check for no description
|
||||||
|
projectdesc=$(sed -e '1q' "$GIT_DIR/description")
|
||||||
|
case "$projectdesc" in
|
||||||
|
"Unnamed repository"* | "")
|
||||||
|
echo "*** Project description file hasn't been set" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Check types
|
||||||
|
# if $newrev is 0000...0000, it's a commit to delete a ref.
|
||||||
|
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
|
||||||
|
if [ "$newrev" = "$zero" ]; then
|
||||||
|
newrev_type=delete
|
||||||
|
else
|
||||||
|
newrev_type=$(git cat-file -t $newrev)
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$refname","$newrev_type" in
|
||||||
|
refs/tags/*,commit)
|
||||||
|
# un-annotated tag
|
||||||
|
short_refname=${refname##refs/tags/}
|
||||||
|
if [ "$allowunannotated" != "true" ]; then
|
||||||
|
echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
|
||||||
|
echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
refs/tags/*,delete)
|
||||||
|
# delete tag
|
||||||
|
if [ "$allowdeletetag" != "true" ]; then
|
||||||
|
echo "*** Deleting a tag is not allowed in this repository" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
refs/tags/*,tag)
|
||||||
|
# annotated tag
|
||||||
|
if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
|
||||||
|
then
|
||||||
|
echo "*** Tag '$refname' already exists." >&2
|
||||||
|
echo "*** Modifying a tag is not allowed in this repository." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
refs/heads/*,commit)
|
||||||
|
# branch
|
||||||
|
if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
|
||||||
|
echo "*** Creating a branch is not allowed in this repository" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
refs/heads/*,delete)
|
||||||
|
# delete branch
|
||||||
|
if [ "$allowdeletebranch" != "true" ]; then
|
||||||
|
echo "*** Deleting a branch is not allowed in this repository" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
refs/remotes/*,commit)
|
||||||
|
# tracking branch
|
||||||
|
;;
|
||||||
|
refs/remotes/*,delete)
|
||||||
|
# delete tracking branch
|
||||||
|
if [ "$allowdeletebranch" != "true" ]; then
|
||||||
|
echo "*** Deleting a tracking branch is not allowed in this repository" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Anything else (is there anything else?)
|
||||||
|
echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- Finished
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# git ls-files --others --exclude-from=.git/info/exclude
|
||||||
|
# Lines that start with '#' are comments.
|
||||||
|
# For a project mostly in C, the following would be a good set of
|
||||||
|
# exclude patterns (uncomment them if you want to use them):
|
||||||
|
# *.[oa]
|
||||||
|
# *~
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
822b85a78b17deb9373a3f5a802e9db2a9893846 eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945 devtest <dev@test.com> 1778145720 +0200 commit: Update README via editor
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
x%ЛБ
|
||||||
|
В0PПы№Jя@¤7/в¤Нbk"›ФаЯ»E�ЛјaжµМ†саpбWБЌЯ…ић¤Ва}4цjC•Vф{"r‡¶)Wў#&Yб.щaхфKПцЙ‘хЏg
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
eed5dac1a3ac99a4e127a1b3b74acedc8c6b2945
|
||||||
@@ -15,7 +15,9 @@
|
|||||||
"@tanstack/react-query": "^5.100.9",
|
"@tanstack/react-query": "^5.100.9",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.15.0",
|
"react-router-dom": "^7.15.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+874
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ const RegisterPage = lazy(() => import('./pages/RegisterPage'))
|
|||||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
||||||
const ReposPage = lazy(() => import('./pages/ReposPage'))
|
const ReposPage = lazy(() => import('./pages/ReposPage'))
|
||||||
const RepoPage = lazy(() => import('./pages/RepoPage'))
|
const RepoPage = lazy(() => import('./pages/RepoPage'))
|
||||||
|
const BlobPage = lazy(() => import('./pages/BlobPage'))
|
||||||
const RepoSettingsPage = lazy(() => import('./pages/RepoSettingsPage'))
|
const RepoSettingsPage = lazy(() => import('./pages/RepoSettingsPage'))
|
||||||
const RepoIssuesPage = lazy(() => import('./pages/RepoIssuesPage'))
|
const RepoIssuesPage = lazy(() => import('./pages/RepoIssuesPage'))
|
||||||
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
|
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
|
||||||
@@ -68,6 +69,7 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="repos" element={<S><ReposPage /></S>} />
|
<Route path="repos" element={<S><ReposPage /></S>} />
|
||||||
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
|
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
|
||||||
|
<Route path="repos/:owner/:repo/blob" element={<S><BlobPage /></S>} />
|
||||||
<Route path="repos/:owner/:repo/commits" element={<S><CommitsPage /></S>} />
|
<Route path="repos/:owner/:repo/commits" element={<S><CommitsPage /></S>} />
|
||||||
<Route path="repos/:owner/:repo/branches" element={<S><BranchesPage /></S>} />
|
<Route path="repos/:owner/:repo/branches" element={<S><BranchesPage /></S>} />
|
||||||
<Route path="repos/:owner/:repo/settings" element={<S><RepoSettingsPage /></S>} />
|
<Route path="repos/:owner/:repo/settings" element={<S><RepoSettingsPage /></S>} />
|
||||||
|
|||||||
@@ -33,10 +33,23 @@ const treeEntrySchema = z.object({
|
|||||||
type: z.enum(['blob', 'tree']),
|
type: z.enum(['blob', 'tree']),
|
||||||
hash: z.string(),
|
hash: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
size: z.number().default(0),
|
||||||
|
commitHash: z.string().default(''),
|
||||||
|
commitMsg: z.string().default(''),
|
||||||
|
commitDate: z.string().default(''),
|
||||||
})
|
})
|
||||||
|
|
||||||
const treeSchema = z.array(treeEntrySchema)
|
const treeSchema = z.array(treeEntrySchema)
|
||||||
|
|
||||||
|
const blobSchema = z.object({
|
||||||
|
content: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
ref: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const branchSchema = z.object({ name: z.string() })
|
||||||
|
const branchesSchema = z.array(branchSchema)
|
||||||
|
|
||||||
export function useRepos() {
|
export function useRepos() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['repos'],
|
queryKey: ['repos'],
|
||||||
@@ -98,6 +111,44 @@ export function useDeleteRepo(owner: string, name: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRepoBranches(owner: string, name: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['repos', owner, name, 'branches'],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<{ name: string }[]>(`/api/v1/repos/${owner}/${name}/branches`, branchesSchema),
|
||||||
|
enabled: Boolean(owner && name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRepoBlob(owner: string, name: string, ref: string, path: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['repos', owner, name, 'blob', ref, path],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<{ content: string; path: string; ref: string }>(
|
||||||
|
`/api/v1/repos/${owner}/${name}/blob?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(path)}`,
|
||||||
|
blobSchema,
|
||||||
|
),
|
||||||
|
enabled: Boolean(owner && name && ref && path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateBlob(owner: string, name: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { path: string; content: string; message: string; branch: string }) =>
|
||||||
|
api.put<{ status: string }>(
|
||||||
|
`/api/v1/repos/${owner}/${name}/blob`,
|
||||||
|
z.object({ status: z.string() }),
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['repos', owner, name, 'blob', vars.branch, vars.path] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['repos', owner, name, 'tree'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['repos', owner, name, 'commits'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateRepo() {
|
export function useCreateRepo() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useRepoTree } from '../../api/queries/repos'
|
import { useRepoTree } from '../../api/queries/repos'
|
||||||
import { Skeleton } from '../../ui/Skeleton'
|
import { Skeleton } from '../../ui/Skeleton'
|
||||||
import { cn } from '../../lib/utils'
|
|
||||||
|
|
||||||
interface TreeBrowserProps {
|
interface TreeBrowserProps {
|
||||||
owner: string
|
owner: string
|
||||||
@@ -11,6 +9,28 @@ interface TreeBrowserProps {
|
|||||||
path?: string
|
path?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const s = Math.floor(diff / 1000)
|
||||||
|
if (s < 60) return `${s}s ago`
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
if (m < 60) return `${m}m ago`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h < 24) return `${h}h ago`
|
||||||
|
const d = Math.floor(h / 24)
|
||||||
|
if (d < 30) return `${d}d ago`
|
||||||
|
const mo = Math.floor(d / 30)
|
||||||
|
if (mo < 12) return `${mo}mo ago`
|
||||||
|
return `${Math.floor(mo / 12)}y ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
||||||
const { data: entries, isLoading, isError } = useRepoTree(owner, repo, ref, path)
|
const { data: entries, isLoading, isError } = useRepoTree(owner, repo, ref, path)
|
||||||
|
|
||||||
@@ -24,10 +44,11 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
|||||||
|
|
||||||
const dirs = entries.filter(e => e.type === 'tree').sort((a, b) => a.name.localeCompare(b.name))
|
const dirs = entries.filter(e => e.type === 'tree').sort((a, b) => a.name.localeCompare(b.name))
|
||||||
const files = entries.filter(e => e.type === 'blob').sort((a, b) => a.name.localeCompare(b.name))
|
const files = entries.filter(e => e.type === 'blob').sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
const sorted = [...dirs, ...files]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-[#DFE1E6] rounded overflow-hidden">
|
<div className="border border-[#DFE1E6] rounded overflow-hidden bg-white">
|
||||||
{/* Breadcrumb */}
|
{/* Path breadcrumb inside tree */}
|
||||||
{path && (
|
{path && (
|
||||||
<div className="flex items-center gap-1 px-3 py-2 bg-[#F4F5F7] border-b border-[#DFE1E6] text-xs text-[#5E6C84]">
|
<div className="flex items-center gap-1 px-3 py-2 bg-[#F4F5F7] border-b border-[#DFE1E6] text-xs text-[#5E6C84]">
|
||||||
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[#0052CC]">{repo}</Link>
|
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[#0052CC]">{repo}</Link>
|
||||||
@@ -37,7 +58,7 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
|||||||
<span key={partial} className="flex items-center gap-1">
|
<span key={partial} className="flex items-center gap-1">
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
{i < arr.length - 1
|
{i < arr.length - 1
|
||||||
? <Link to={`/repos/${owner}/${repo}?path=${partial}`} className="hover:text-[#0052CC]">{seg}</Link>
|
? <Link to={`/repos/${owner}/${repo}?path=${partial}&ref=${ref}`} className="hover:text-[#0052CC]">{seg}</Link>
|
||||||
: <span className="text-[#172B4D] font-medium">{seg}</span>
|
: <span className="text-[#172B4D] font-medium">{seg}</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
@@ -46,83 +67,73 @@ export function TreeBrowser({ owner, repo, ref, path = '' }: TreeBrowserProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Entries */}
|
<table className="w-full text-sm border-collapse">
|
||||||
<ul>
|
<colgroup>
|
||||||
{[...dirs, ...files].map((entry, i) => {
|
<col className="w-auto" />
|
||||||
const entryPath = path ? `${path}/${entry.name}` : entry.name
|
<col className="w-48 hidden sm:table-column" />
|
||||||
const isDir = entry.type === 'tree'
|
<col className="w-28" />
|
||||||
const href = isDir
|
</colgroup>
|
||||||
? `/repos/${owner}/${repo}?path=${entryPath}`
|
<tbody>
|
||||||
: `/repos/${owner}/${repo}/blob?ref=${ref}&path=${entryPath}`
|
{sorted.map(entry => {
|
||||||
|
const entryPath = path ? `${path}/${entry.name}` : entry.name
|
||||||
|
const isDir = entry.type === 'tree'
|
||||||
|
const href = isDir
|
||||||
|
? `/repos/${owner}/${repo}?path=${encodeURIComponent(entryPath)}&ref=${ref}`
|
||||||
|
: `/repos/${owner}/${repo}/blob?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(entryPath)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<tr key={entry.hash} className="border-b border-[#DFE1E6] last:border-b-0 hover:bg-[#FAFBFC]">
|
||||||
key={entry.hash}
|
{/* Name */}
|
||||||
className={cn(
|
<td className="px-3 py-2">
|
||||||
'flex items-center gap-3 px-3 py-2 hover:bg-[#FAFBFC] text-sm border-b border-[#DFE1E6] last:border-b-0',
|
<div className="flex items-center gap-2">
|
||||||
i === 0 && !path && 'border-t-0',
|
{isDir ? (
|
||||||
)}
|
<svg width="16" height="16" fill="#0052CC" viewBox="0 0 24 24" className="shrink-0">
|
||||||
>
|
<path d="M19.5 21h-15A2.25 2.25 0 0 1 2.25 18.75V6.75A2.25 2.25 0 0 1 4.5 4.5h4.086c.398 0 .779.158 1.06.44l1.415 1.414c.28.281.661.44 1.06.44H19.5A2.25 2.25 0 0 1 21.75 9v9.75A2.25 2.25 0 0 1 19.5 21Z" />
|
||||||
{isDir ? (
|
</svg>
|
||||||
<svg width="16" height="16" fill="#0052CC" viewBox="0 0 24 24" className="shrink-0">
|
) : (
|
||||||
<path d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
|
<svg width="16" height="16" fill="none" stroke="#5E6C84" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||||
) : (
|
</svg>
|
||||||
<svg width="16" height="16" fill="none" stroke="#5E6C84" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
|
)}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
<Link
|
||||||
</svg>
|
to={href}
|
||||||
)}
|
className={isDir ? 'text-[#0052CC] hover:underline font-medium' : 'text-[#172B4D] hover:text-[#0052CC]'}
|
||||||
<Link
|
>
|
||||||
to={href}
|
{entry.name}
|
||||||
className={cn(
|
</Link>
|
||||||
'flex-1 truncate',
|
{!isDir && entry.size > 0 && (
|
||||||
isDir ? 'text-[#0052CC] hover:underline' : 'text-[#172B4D] hover:text-[#0052CC]',
|
<span className="text-[10px] text-[#5E6C84] hidden sm:inline">{formatSize(entry.size)}</span>
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
{entry.name}
|
</td>
|
||||||
</Link>
|
{/* Commit message */}
|
||||||
</li>
|
<td className="px-3 py-2 text-xs text-[#5E6C84] truncate max-w-0 hidden sm:table-cell">
|
||||||
)
|
<span className="truncate block" title={entry.commitMsg}>{entry.commitMsg}</span>
|
||||||
})}
|
</td>
|
||||||
</ul>
|
{/* Date */}
|
||||||
|
<td className="px-3 py-2 text-xs text-[#5E6C84] whitespace-nowrap text-right">
|
||||||
|
{relativeTime(entry.commitDate)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TreeSkeleton() {
|
function TreeSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="border border-[#DFE1E6] rounded overflow-hidden">
|
<div className="border border-[#DFE1E6] rounded overflow-hidden bg-white">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-3 px-3 py-2 border-b border-[#DFE1E6] last:border-b-0">
|
<div key={i} className="flex items-center gap-3 px-3 py-2.5 border-b border-[#DFE1E6] last:border-b-0">
|
||||||
<Skeleton className="w-4 h-4 shrink-0" />
|
<Skeleton className="w-4 h-4 shrink-0" />
|
||||||
<Skeleton className={`h-4 ${i % 2 === 0 ? 'w-32' : 'w-48'}`} />
|
<Skeleton className={`h-4 ${i % 3 === 0 ? 'w-20' : i % 3 === 1 ? 'w-36' : 'w-28'}`} />
|
||||||
|
<Skeleton className="h-3 w-40 ml-auto hidden sm:block" />
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapsible row used in inline tree navigation
|
|
||||||
export function TreeRow({
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
href,
|
|
||||||
}: {
|
|
||||||
name: string
|
|
||||||
type: 'blob' | 'tree'
|
|
||||||
href: string
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Link to={href} className="flex items-center gap-2 py-1 hover:text-[#0052CC] text-sm">
|
|
||||||
{type === 'tree' && (
|
|
||||||
<button onClick={e => { e.preventDefault(); setOpen(o => !o) }} className="w-4 text-[#5E6C84]">
|
|
||||||
{open ? '▾' : '▸'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<span>{name}</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { useRepo, useRepoBlob, useUpdateBlob } from '../api/queries/repos'
|
||||||
|
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||||
|
|
||||||
|
export default function BlobPage() {
|
||||||
|
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [editContent, setEditContent] = useState('')
|
||||||
|
const [commitMsg, setCommitMsg] = useState('')
|
||||||
|
const [preview, setPreview] = useState(false)
|
||||||
|
|
||||||
|
const ref = searchParams.get('ref') ?? ''
|
||||||
|
const filePath = searchParams.get('path') ?? ''
|
||||||
|
const fileName = filePath.split('/').pop() ?? filePath
|
||||||
|
|
||||||
|
const { data: repo } = useRepo(owner, repoName)
|
||||||
|
const { data: blob, isLoading, isError } = useRepoBlob(owner, repoName, ref, filePath)
|
||||||
|
const updateBlob = useUpdateBlob(owner, repoName)
|
||||||
|
|
||||||
|
const branch = ref || repo?.defaultBranch || 'main'
|
||||||
|
const isMarkdown = fileName.toLowerCase().endsWith('.md')
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
setEditContent(blob?.content ?? '')
|
||||||
|
setCommitMsg(`Update ${fileName}`)
|
||||||
|
setEditing(true)
|
||||||
|
setPreview(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditing(false)
|
||||||
|
setPreview(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommit() {
|
||||||
|
if (!commitMsg.trim() || !filePath) return
|
||||||
|
await updateBlob.mutateAsync({
|
||||||
|
path: filePath,
|
||||||
|
content: editContent,
|
||||||
|
message: commitMsg.trim(),
|
||||||
|
branch,
|
||||||
|
})
|
||||||
|
setEditing(false)
|
||||||
|
navigate(`/repos/${owner}/${repoName}/blob?ref=${encodeURIComponent(branch)}&path=${encodeURIComponent(filePath)}`, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div>
|
||||||
|
if (isError || !blob) return <div className="p-6 text-sm text-[#DE350B]">File not found.</div>
|
||||||
|
|
||||||
|
const lines = blob.content.split('\n')
|
||||||
|
const pathParts = filePath.split('/')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4">
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-1 text-sm flex-wrap">
|
||||||
|
<Link to="/repos" className="text-[#0052CC] hover:underline">Repositories</Link>
|
||||||
|
<span className="text-[#5E6C84]">/</span>
|
||||||
|
<Link to={`/repos/${owner}/${repoName}`} className="text-[#0052CC] hover:underline">{repoName}</Link>
|
||||||
|
{pathParts.map((seg, i) => {
|
||||||
|
const partial = pathParts.slice(0, i + 1).join('/')
|
||||||
|
const isLast = i === pathParts.length - 1
|
||||||
|
return (
|
||||||
|
<span key={partial} className="flex items-center gap-1">
|
||||||
|
<span className="text-[#5E6C84]">/</span>
|
||||||
|
{isLast
|
||||||
|
? <span className="font-semibold text-[#172B4D]">{seg}</span>
|
||||||
|
: <Link to={`/repos/${owner}/${repoName}?path=${encodeURIComponent(partial)}&ref=${encodeURIComponent(branch)}`} className="text-[#0052CC] hover:underline">{seg}</Link>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File card */}
|
||||||
|
<div className="border border-[#DFE1E6] rounded bg-white overflow-hidden">
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[#DFE1E6] bg-[#FAFBFC] gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{/* Branch pill */}
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 border border-[#DFE1E6] rounded text-xs text-[#5E6C84] bg-white">
|
||||||
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||||
|
</svg>
|
||||||
|
{branch}
|
||||||
|
</span>
|
||||||
|
<span className="text-[#5E6C84]">{repoName}</span>
|
||||||
|
<span className="text-[#5E6C84]">/</span>
|
||||||
|
<span className="font-medium text-[#172B4D]">{fileName}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(filePath)}
|
||||||
|
className="text-[#5E6C84] hover:text-[#172B4D]"
|
||||||
|
title="Copy path"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!editing && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isMarkdown && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPreview(p => !p)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded border ${preview ? 'border-[#0052CC] text-[#0052CC] bg-[#DEEBFF]' : 'border-[#DFE1E6] text-[#5E6C84] hover:bg-[#F4F5F7]'}`}
|
||||||
|
>
|
||||||
|
{preview ? 'Source' : 'Preview'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={startEdit}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium border border-[#DFE1E6] rounded text-[#172B4D] hover:bg-[#F4F5F7] flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(blob.content)}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium border border-[#DFE1E6] rounded text-[#5E6C84] hover:bg-[#F4F5F7]"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={e => setEditContent(e.target.value)}
|
||||||
|
className="w-full font-mono text-xs text-[#172B4D] bg-white p-4 resize-none focus:outline-none border-b border-[#DFE1E6]"
|
||||||
|
style={{ minHeight: Math.max(300, lines.length * 20) }}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<div className="p-4 bg-[#FAFBFC] border-t border-[#DFE1E6] space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Commit message</label>
|
||||||
|
<input
|
||||||
|
value={commitMsg}
|
||||||
|
onChange={e => setCommitMsg(e.target.value)}
|
||||||
|
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]"
|
||||||
|
placeholder="Describe your changes…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCommit}
|
||||||
|
disabled={updateBlob.isPending || !commitMsg.trim()}
|
||||||
|
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateBlob.isPending ? 'Committing…' : 'Commit changes'}
|
||||||
|
</button>
|
||||||
|
<button onClick={cancelEdit} className="px-4 py-2 rounded border border-[#DFE1E6] text-sm text-[#172B4D] hover:bg-[#F4F5F7]">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{updateBlob.isError && (
|
||||||
|
<span className="text-xs text-[#DE350B]">{(updateBlob.error as Error)?.message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isMarkdown && preview ? (
|
||||||
|
<div className="px-6 py-5 prose prose-sm max-w-none text-[#172B4D]
|
||||||
|
prose-headings:text-[#172B4D] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[#DFE1E6] prose-headings:pb-1
|
||||||
|
prose-a:text-[#0052CC] prose-code:bg-[#F4F5F7] prose-code:px-1 prose-code:rounded
|
||||||
|
prose-pre:bg-[#F4F5F7] prose-pre:border prose-pre:border-[#DFE1E6] prose-pre:rounded">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{blob.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse font-mono text-xs">
|
||||||
|
<tbody>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<tr key={i} className="hover:bg-[#FFFBDD]">
|
||||||
|
<td className="select-none text-right text-[#5E6C84] px-4 py-0.5 w-12 border-r border-[#DFE1E6] bg-[#FAFBFC] sticky left-0">
|
||||||
|
{i + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-0.5 text-[#172B4D] whitespace-pre">{line || ' '}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+167
-58
@@ -1,68 +1,200 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
import { useParams, useSearchParams, Link } from 'react-router-dom'
|
||||||
import { useRepo } from '../api/queries/repos'
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
|
||||||
import { TreeBrowser } from '../components/repos/TreeBrowser'
|
import { TreeBrowser } from '../components/repos/TreeBrowser'
|
||||||
import { RepoListSkeleton } from '../ui/Skeleton'
|
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||||
import { useRecentRepos } from '../hooks/useRecentRepos'
|
import { useRecentRepos } from '../hooks/useRecentRepos'
|
||||||
|
|
||||||
export default function RepoPage() {
|
export default function RepoPage() {
|
||||||
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
|
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const [showBranches, setShowBranches] = useState(false)
|
||||||
|
const [showClone, setShowClone] = useState(false)
|
||||||
|
const branchRef = useRef<HTMLDivElement>(null)
|
||||||
|
const cloneRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const path = searchParams.get('path') ?? ''
|
const path = searchParams.get('path') ?? ''
|
||||||
const ref = searchParams.get('ref') ?? ''
|
const ref = searchParams.get('ref') ?? ''
|
||||||
|
|
||||||
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
|
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
|
||||||
|
const { data: branches } = useRepoBranches(owner, repoName)
|
||||||
const { track } = useRecentRepos()
|
const { track } = useRecentRepos()
|
||||||
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
|
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
|
||||||
|
|
||||||
|
// Close dropdowns on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
function handle(e: MouseEvent) {
|
||||||
|
if (branchRef.current && !branchRef.current.contains(e.target as Node)) setShowBranches(false)
|
||||||
|
if (cloneRef.current && !cloneRef.current.contains(e.target as Node)) setShowClone(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handle)
|
||||||
|
return () => document.removeEventListener('mousedown', handle)
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div>
|
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div>
|
||||||
if (isError || !repo) return <div className="p-6 text-sm text-[#DE350B]">Repository not found.</div>
|
if (isError || !repo) return <div className="p-6 text-sm text-[#DE350B]">Repository not found.</div>
|
||||||
|
|
||||||
const branch = ref || repo.defaultBranch
|
const branch = ref || repo.defaultBranch
|
||||||
const cloneUrl = `http://localhost:8080/${owner}/${repoName}.git`
|
const cloneUrl = `${window.location.origin}/${owner}/${repoName}.git`
|
||||||
|
|
||||||
|
function switchBranch(b: string) {
|
||||||
|
setSearchParams({ ref: b, ...(path ? { path } : {}) })
|
||||||
|
setShowBranches(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-6">
|
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4">
|
||||||
{/* Breadcrumb */}
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
<Link to="/repos" className="text-[#0052CC] hover:underline">Repositories</Link>
|
|
||||||
<span className="text-[#5E6C84]">/</span>
|
|
||||||
<span className="font-semibold text-[#172B4D]">{repo.name}</span>
|
|
||||||
{repo.isPrivate && (
|
|
||||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-[#DFE1E6] text-[#5E6C84] ml-1">
|
|
||||||
Private
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{repo.description && (
|
{/* Header row */}
|
||||||
<p className="text-sm text-[#5E6C84]">{repo.description}</p>
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
)}
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[#5E6C84] mb-1">
|
||||||
|
<Link to="/repos" className="hover:text-[#0052CC]">Repositories</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="font-semibold text-[#172B4D]">{repo.name}</span>
|
||||||
|
{repo.isPrivate && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-[#DFE1E6] text-[#5E6C84]">
|
||||||
|
Private
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{repo.description && (
|
||||||
|
<p className="text-sm text-[#5E6C84]">{repo.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Link
|
||||||
|
to={`/repos/${owner}/${repoName}/pulls`}
|
||||||
|
className="px-3 py-1.5 border border-[#DFE1E6] rounded text-sm text-[#172B4D] hover:bg-[#F4F5F7] font-medium"
|
||||||
|
>
|
||||||
|
Pull requests
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Clone dropdown */}
|
||||||
|
<div className="relative" ref={cloneRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowClone(s => !s)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-[#0052CC] hover:bg-[#0065FF] text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
|
||||||
|
</svg>
|
||||||
|
Clone
|
||||||
|
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24" className="ml-0.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{showClone && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 w-80 bg-white border border-[#DFE1E6] rounded-lg shadow-xl z-50 p-4">
|
||||||
|
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">Clone over HTTP</p>
|
||||||
|
<div className="flex items-center gap-2 bg-[#F4F5F7] border border-[#DFE1E6] rounded px-3 py-2">
|
||||||
|
<code className="text-xs text-[#172B4D] flex-1 truncate">{cloneUrl}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(cloneUrl)}
|
||||||
|
className="text-[10px] text-[#0052CC] hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{repo.isEmpty ? (
|
{repo.isEmpty ? (
|
||||||
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} />
|
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Branch pill + repo nav tabs */}
|
{/* Branch selector */}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border border-[#DFE1E6] rounded text-sm text-[#172B4D]">
|
<div className="relative" ref={branchRef}>
|
||||||
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
<button
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
onClick={() => setShowBranches(s => !s)}
|
||||||
</svg>
|
className="flex items-center gap-1.5 px-3 py-1.5 border border-[#DFE1E6] rounded text-sm text-[#172B4D] hover:bg-[#F4F5F7] font-medium bg-white"
|
||||||
{branch}
|
>
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||||
|
</svg>
|
||||||
|
{branch}
|
||||||
|
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{showBranches && (
|
||||||
|
<div className="absolute left-0 top-full mt-1 w-56 bg-white border border-[#DFE1E6] rounded-lg shadow-xl z-50 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 border-b border-[#DFE1E6] bg-[#F4F5F7]">
|
||||||
|
<p className="text-xs font-semibold text-[#5E6C84]">Switch branch</p>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{branches?.map(b => (
|
||||||
|
<li key={b.name}>
|
||||||
|
<button
|
||||||
|
onClick={() => switchBranch(b.name)}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-[#F4F5F7] flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{b.name === branch && (
|
||||||
|
<svg width="12" height="12" fill="none" stroke="#0052CC" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className={b.name === branch ? 'text-[#0052CC] font-medium' : 'text-[#172B4D] ml-5'}>{b.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{!branches?.length && (
|
||||||
|
<li className="px-3 py-2 text-xs text-[#5E6C84]">No branches found</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link to={`/repos/${owner}/${repoName}/issues`} className="text-sm text-[#0052CC] hover:underline">Issues</Link>
|
|
||||||
<Link to={`/repos/${owner}/${repoName}/pulls`} className="text-sm text-[#0052CC] hover:underline">Pull requests</Link>
|
{/* Nav links */}
|
||||||
<Link to={`/repos/${owner}/${repoName}/settings`} className="text-sm text-[#5E6C84] hover:text-[#172B4D] hover:underline ml-auto">Settings</Link>
|
<Link to={`/repos/${owner}/${repoName}/commits`} className="text-sm text-[#5E6C84] hover:text-[#172B4D] px-2 py-1">Commits</Link>
|
||||||
|
<Link to={`/repos/${owner}/${repoName}/branches`} className="text-sm text-[#5E6C84] hover:text-[#172B4D] px-2 py-1">Branches</Link>
|
||||||
|
<Link to={`/repos/${owner}/${repoName}/issues`} className="text-sm text-[#5E6C84] hover:text-[#172B4D] px-2 py-1">Issues</Link>
|
||||||
|
<Link to={`/repos/${owner}/${repoName}/settings`} className="text-sm text-[#5E6C84] hover:text-[#172B4D] px-2 py-1 ml-auto">Settings</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TreeBrowser owner={owner} repo={repoName} ref={branch} path={path} />
|
<TreeBrowser owner={owner} repo={repoName} ref={branch} path={path} />
|
||||||
|
|
||||||
|
{/* README preview — only at repo root */}
|
||||||
|
{!path && <ReadmePreview owner={owner} repo={repoName} ref={branch} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReadmePreview({ owner, repo, ref }: { owner: string; repo: string; ref: string }) {
|
||||||
|
const { data: entries } = useRepoTree(owner, repo, ref, '')
|
||||||
|
const readmeEntry = entries?.find(e => e.name.toLowerCase() === 'readme.md')
|
||||||
|
const { data: blob } = useRepoBlob(owner, repo, ref, readmeEntry?.name ?? '')
|
||||||
|
|
||||||
|
if (!readmeEntry || !blob) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-[#DFE1E6] rounded bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-2.5 border-b border-[#DFE1E6] bg-[#FAFBFC] flex items-center gap-2">
|
||||||
|
<svg width="14" height="14" fill="none" stroke="#5E6C84" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-semibold text-[#172B4D]">{readmeEntry.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-5 prose prose-sm max-w-none text-[#172B4D]
|
||||||
|
prose-headings:text-[#172B4D] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[#DFE1E6] prose-headings:pb-1
|
||||||
|
prose-a:text-[#0052CC] prose-code:bg-[#F4F5F7] prose-code:px-1 prose-code:rounded prose-code:text-sm
|
||||||
|
prose-pre:bg-[#F4F5F7] prose-pre:border prose-pre:border-[#DFE1E6] prose-pre:rounded">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{blob.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function GettingStarted({ repoName, branch, cloneUrl }: {
|
function GettingStarted({ repoName, branch, cloneUrl }: {
|
||||||
repoName: string; branch: string; cloneUrl: string
|
repoName: string; branch: string; cloneUrl: string
|
||||||
}) {
|
}) {
|
||||||
@@ -70,42 +202,20 @@ function GettingStarted({ repoName, branch, cloneUrl }: {
|
|||||||
<div className="border border-[#DFE1E6] rounded-lg overflow-hidden">
|
<div className="border border-[#DFE1E6] rounded-lg overflow-hidden">
|
||||||
<div className="px-5 py-4 bg-[#FAFBFC] border-b border-[#DFE1E6]">
|
<div className="px-5 py-4 bg-[#FAFBFC] border-b border-[#DFE1E6]">
|
||||||
<h2 className="text-sm font-semibold text-[#172B4D]">Getting started</h2>
|
<h2 className="text-sm font-semibold text-[#172B4D]">Getting started</h2>
|
||||||
<p className="text-xs text-[#5E6C84] mt-0.5">
|
<p className="text-xs text-[#5E6C84] mt-0.5">Push your first commit to get started.</p>
|
||||||
This repository is empty. Push your first commit to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-5 space-y-6 text-sm">
|
<div className="px-5 py-5 space-y-6 text-sm">
|
||||||
{/* Quick setup */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">
|
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">Clone over HTTP</p>
|
||||||
Quick setup — clone over HTTP
|
|
||||||
</p>
|
|
||||||
<CopyBlock value={cloneUrl} />
|
<CopyBlock value={cloneUrl} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Push an existing repo */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">
|
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">…or push an existing repository</p>
|
||||||
…or push an existing repository
|
<CopyBlock value={`git remote add origin ${cloneUrl}\ngit branch -M ${branch}\ngit push -u origin ${branch}`} multiline />
|
||||||
</p>
|
|
||||||
<CopyBlock value={`git remote add origin ${cloneUrl}
|
|
||||||
git branch -M ${branch}
|
|
||||||
git push -u origin ${branch}`} multiline />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create new */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">
|
<p className="text-xs font-semibold text-[#5E6C84] uppercase tracking-wide mb-2">…or create a new repository on the command line</p>
|
||||||
…or create a new repository on the command line
|
<CopyBlock value={`echo "# ${repoName}" >> README.md\ngit init\ngit add README.md\ngit commit -m "first commit"\ngit branch -M ${branch}\ngit remote add origin ${cloneUrl}\ngit push -u origin ${branch}`} multiline />
|
||||||
</p>
|
|
||||||
<CopyBlock value={`echo "# ${repoName}" >> README.md
|
|
||||||
git init
|
|
||||||
git add README.md
|
|
||||||
git commit -m "first commit"
|
|
||||||
git branch -M ${branch}
|
|
||||||
git remote add origin ${cloneUrl}
|
|
||||||
git push -u origin ${branch}`} multiline />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +224,6 @@ git push -u origin ${branch}`} multiline />
|
|||||||
|
|
||||||
function CopyBlock({ value, multiline }: { value: string; multiline?: boolean }) {
|
function CopyBlock({ value, multiline }: { value: string; multiline?: boolean }) {
|
||||||
const copy = () => navigator.clipboard.writeText(value).catch(() => {})
|
const copy = () => navigator.clipboard.writeText(value).catch(() => {})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<pre className={`font-mono text-xs bg-[#F4F5F7] border border-[#DFE1E6] rounded px-4 py-3 overflow-x-auto text-[#172B4D] ${multiline ? 'whitespace-pre' : 'whitespace-nowrap'}`}>
|
<pre className={`font-mono text-xs bg-[#F4F5F7] border border-[#DFE1E6] rounded px-4 py-3 overflow-x-auto text-[#172B4D] ${multiline ? 'whitespace-pre' : 'whitespace-nowrap'}`}>
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export interface TreeEntry {
|
|||||||
type: 'blob' | 'tree'
|
type: 'blob' | 'tree'
|
||||||
hash: string
|
hash: string
|
||||||
name: string
|
name: string
|
||||||
|
size: number
|
||||||
|
commitHash: string
|
||||||
|
commitMsg: string
|
||||||
|
commitDate: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Commit {
|
export interface Commit {
|
||||||
|
|||||||
@@ -160,6 +160,54 @@ func (h *RepoHandler) Blob(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, map[string]string{"content": string(content), "path": path, "ref": ref})
|
jsonOK(w, map[string]string{"content": string(content), "path": path, "ref": ref})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *RepoHandler) UpdateBlob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo, ok := h.lookupRepo(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var u models.User
|
||||||
|
if has, _ := h.db.ID(userID).Get(&u); !has {
|
||||||
|
jsonError(w, "user not found", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, "invalid body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Path == "" || req.Message == "" {
|
||||||
|
jsonError(w, "path and message are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Branch == "" {
|
||||||
|
req.Branch = repo.DefaultBranch
|
||||||
|
}
|
||||||
|
|
||||||
|
authorEmail := u.Email
|
||||||
|
if authorEmail == "" {
|
||||||
|
authorEmail = u.Username + "@forgebucket.local"
|
||||||
|
}
|
||||||
|
|
||||||
|
gitdomain.SetRepoRoot(h.cfg.RepoRoot)
|
||||||
|
if err := gitdomain.WriteFile(repo.DiskPath, req.Branch, req.Path, req.Content, u.Username, authorEmail, req.Message); err != nil {
|
||||||
|
jsonError(w, "could not save file: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *RepoHandler) Branches(w http.ResponseWriter, r *http.Request) {
|
func (h *RepoHandler) Branches(w http.ResponseWriter, r *http.Request) {
|
||||||
repo, ok := h.lookupRepo(w, r)
|
repo, ok := h.lookupRepo(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
|
|||||||
r.With(csrf).Delete("/", repoH.Delete)
|
r.With(csrf).Delete("/", repoH.Delete)
|
||||||
r.Get("/tree", repoH.Tree)
|
r.Get("/tree", repoH.Tree)
|
||||||
r.Get("/blob", repoH.Blob)
|
r.Get("/blob", repoH.Blob)
|
||||||
|
r.With(csrf).Put("/blob", repoH.UpdateBlob)
|
||||||
r.Get("/commits", repoH.Commits)
|
r.Get("/commits", repoH.Commits)
|
||||||
r.Get("/branches", repoH.Branches)
|
r.Get("/branches", repoH.Branches)
|
||||||
r.Get("/diff", repoH.Diff)
|
r.Get("/diff", repoH.Diff)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package git
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -117,14 +118,17 @@ func Log(repoPath, branch string, limit int) ([]Commit, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TreeEntry struct {
|
type TreeEntry struct {
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
CommitHash string `json:"commitHash"`
|
||||||
|
CommitMsg string `json:"commitMsg"`
|
||||||
|
CommitDate string `json:"commitDate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) {
|
func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) {
|
||||||
// Short-circuit for repos with no commits yet.
|
|
||||||
if IsEmpty(repoPath) {
|
if IsEmpty(repoPath) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -132,7 +136,8 @@ func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) {
|
|||||||
if subPath != "" {
|
if subPath != "" {
|
||||||
treeRef = ref + ":" + subPath
|
treeRef = ref + ":" + subPath
|
||||||
}
|
}
|
||||||
out, err := run(repoPath, "ls-tree", treeRef)
|
// -l adds size column: <mode> SP <type> SP <hash> SP <size> TAB <name>
|
||||||
|
out, err := run(repoPath, "ls-tree", "-l", treeRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -142,26 +147,109 @@ func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) {
|
|||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// format: <mode> SP <type> SP <hash> TAB <name>
|
|
||||||
tabIdx := strings.Index(line, "\t")
|
tabIdx := strings.Index(line, "\t")
|
||||||
if tabIdx < 0 {
|
if tabIdx < 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := line[tabIdx+1:]
|
name := line[tabIdx+1:]
|
||||||
fields := strings.Fields(line[:tabIdx])
|
fields := strings.Fields(line[:tabIdx])
|
||||||
if len(fields) != 3 {
|
if len(fields) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entries = append(entries, TreeEntry{
|
e := TreeEntry{
|
||||||
Mode: fields[0],
|
Mode: fields[0],
|
||||||
Type: fields[1],
|
Type: fields[1],
|
||||||
Hash: fields[2],
|
Hash: fields[2],
|
||||||
Name: name,
|
Name: name,
|
||||||
})
|
}
|
||||||
|
if fields[3] != "-" {
|
||||||
|
fmt.Sscanf(fields[3], "%d", &e.Size)
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch last commit info for each entry.
|
||||||
|
for i, e := range entries {
|
||||||
|
filePath := e.Name
|
||||||
|
if subPath != "" {
|
||||||
|
filePath = subPath + "/" + e.Name
|
||||||
|
}
|
||||||
|
commitOut, err := run(repoPath, "log", "-1", "--format=%h\x1f%s\x1f%aI", "--", filePath)
|
||||||
|
if err == nil {
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(string(commitOut)), "\x1f", 3)
|
||||||
|
if len(parts) == 3 {
|
||||||
|
entries[i].CommitHash = parts[0]
|
||||||
|
entries[i].CommitMsg = parts[1]
|
||||||
|
entries[i].CommitDate = parts[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteFile writes content to filePath on branch inside a temporary worktree,
|
||||||
|
// then commits with the given author and message. Uses git plumbing directly
|
||||||
|
// so it works on bare repositories.
|
||||||
|
func WriteFile(repoPath, branch, filePath, content, authorName, authorEmail, message string) error {
|
||||||
|
clean := filepath.Clean(filepath.FromSlash(filePath))
|
||||||
|
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||||||
|
return errors.New("invalid file path")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "fb-edit-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mktemp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
|
||||||
|
authorEnv := append(baseEnv,
|
||||||
|
"GIT_AUTHOR_NAME="+authorName,
|
||||||
|
"GIT_AUTHOR_EMAIL="+authorEmail,
|
||||||
|
"GIT_COMMITTER_NAME="+authorName,
|
||||||
|
"GIT_COMMITTER_EMAIL="+authorEmail,
|
||||||
|
)
|
||||||
|
|
||||||
|
addWt := exec.Command("git", "worktree", "add", "--force", tmpDir, branch)
|
||||||
|
addWt.Dir = filepath.Clean(repoPath)
|
||||||
|
addWt.Env = baseEnv
|
||||||
|
if out, err := addWt.CombinedOutput(); err != nil {
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
return fmt.Errorf("worktree add: %w: %s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
rmWt := exec.Command("git", "worktree", "remove", "--force", tmpDir)
|
||||||
|
rmWt.Dir = filepath.Clean(repoPath)
|
||||||
|
rmWt.Env = baseEnv
|
||||||
|
rmWt.Run()
|
||||||
|
os.RemoveAll(tmpDir)
|
||||||
|
}()
|
||||||
|
|
||||||
|
fullPath := filepath.Join(tmpDir, clean)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||||||
|
return fmt.Errorf("mkdirall: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
|
||||||
|
return fmt.Errorf("writefile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addC := exec.Command("git", "add", clean)
|
||||||
|
addC.Dir = tmpDir
|
||||||
|
addC.Env = authorEnv
|
||||||
|
if out, err := addC.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("git add: %w: %s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
commitC := exec.Command("git", "commit", "-m", message)
|
||||||
|
commitC.Dir = tmpDir
|
||||||
|
commitC.Env = authorEnv
|
||||||
|
if out, err := commitC.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("git commit: %w: %s", err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type Branch struct {
|
type Branch struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user