diff --git a/.github/workflows/build_test_release.yml b/.github/workflows/build_test_release.yml index ee035c2..89501fc 100644 --- a/.github/workflows/build_test_release.yml +++ b/.github/workflows/build_test_release.yml @@ -13,7 +13,7 @@ jobs: uses: actions/setup-go@v3 - uses: actions/checkout@v3 - name: Setup Go Workfile - run: go work init ./ci ./src_code/go_src + run: go work init ./ci ./ - run: cd ci && go get dagger.io/dagger@latest && cd .. - run: mkdir ./dist - run: go run ci/build_test_release.go diff --git a/.github/workflows/test_push.yml b/.github/workflows/test_push.yml index 9a039a3..da15206 100644 --- a/.github/workflows/test_push.yml +++ b/.github/workflows/test_push.yml @@ -16,7 +16,7 @@ jobs: uses: actions/setup-go@v3 - uses: actions/checkout@v3 - name: Setup Go Workfile - run: go work init ./ci ./src_code/go_src + run: go work init ./ci ./ - name: Get Dagger run: cd ci && go get dagger.io/dagger@latest && cd .. - name: Run Dagger Test Workflow diff --git a/ci/build_test_release.go b/ci/build_test_release.go index aabc2d3..861b2c3 100644 --- a/ci/build_test_release.go +++ b/ci/build_test_release.go @@ -24,7 +24,7 @@ func main() { // mount the source code directory on the host // at /src in the container source := client.Container(). - From("golang:1.22"). + From("golang:1.23"). WithDirectory("/src", client.Host().Directory(".", dagger.HostDirectoryOpts{ Exclude: []string{}, })).WithMountedCache("/src/dagger_dep_cache/go_dep", goCache) diff --git a/ci/test_on_push.go b/ci/test_on_push.go index efc04ca..60ae112 100644 --- a/ci/test_on_push.go +++ b/ci/test_on_push.go @@ -24,7 +24,7 @@ func main() { // mount the source code directory on the host // at /src in the container source := client.Container(). - From("golang:1.22"). + From("golang:1.23"). WithDirectory("/src", client.Host().Directory(".", dagger.HostDirectoryOpts{ Exclude: []string{"build/"}, })).WithMountedCache("/src/dagger_dep_cache/go_dep", goCache) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0518b16 --- /dev/null +++ b/go.mod @@ -0,0 +1,48 @@ +module github.com/Slug-Boi/cocommit + +go 1.23.2 + +require ( + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbletea v1.1.2 + github.com/charmbracelet/glamour v0.8.0 + github.com/charmbracelet/lipgloss v0.13.1 + github.com/charmbracelet/x/exp/teatest v0.0.0-20241024145942-ad25fd0d5a9e + github.com/inancgumus/screen v0.0.0-20190314163918-06e984b86ed3 + github.com/spf13/cobra v1.8.1 +) + +require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymanbagabas/go-udiff v0.2.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/x/ansi v0.4.0 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect + github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/yuin/goldmark v1.7.4 // indirect + github.com/yuin/goldmark-emoji v1.0.3 // indirect + golang.org/x/crypto v0.25.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/term v0.22.0 // indirect + golang.org/x/text v0.19.0 // indirect +) diff --git a/src_code/go_src/LICENSE b/src_code/go_src/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/src_code/go_src/author_file b/src_code/go_src/author_file new file mode 100644 index 0000000..7a65fb8 --- /dev/null +++ b/src_code/go_src/author_file @@ -0,0 +1,4 @@ +Syntax for authorfile +test|test|test|test|ex;;test +tet|tessadsat|teta|asdadad|ex;;adsadas +teadsajdma|asdasdasda|adsdadasd|addsadasd;;adsadsadadas \ No newline at end of file diff --git a/src_code/go_src/cmd/cmd_test.go b/src_code/go_src/cmd/cmd_test.go new file mode 100644 index 0000000..790e46b --- /dev/null +++ b/src_code/go_src/cmd/cmd_test.go @@ -0,0 +1,149 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + + "github.com/Slug-Boi/cocommit/src_code/go_src/cmd/utils" +) + +const author_data = `syntax for the test file +te|testing|TestUser|test@test.test|ex +ti|testtest|UserName2|testing@user.io;;gr1` + +var envVar = utils.Find_authorfile() + +func setup() { + // setup test data + err := os.WriteFile("author_file_test", []byte(author_data), 0644) + if err != nil { + panic(err) + } + os.Setenv("author_file", "author_file_test") + envVar = os.Getenv("author_file") +} + +func teardown() { + // remove test data + os.Remove("author_file_test") + os.Setenv("author_file", envVar) +} + +func StdoutReader() (chan string, *os.File, *os.File, *os.File) { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + outC := make(chan string) + return outC, r, w, old +} + +// users CMD TEST BEGIN +func Test_UsersCmd(t *testing.T) { + setup() + defer teardown() + + //stdout reader + outC, r, w, old := StdoutReader() + + go func() { + var buf bytes.Buffer + io.Copy(&buf, r) + outC <- buf.String() + }() + + cmd := UsersCmd() + authorfile = "author_file_test" + b := new(bytes.Buffer) + cmd.SetErr(b) + cmd.Execute() + + out, err := io.ReadAll(b) + if err != nil { + t.Fatal(err) + } + + w.Close() + os.Stdout = old + outStr := <-outC + if outStr == "" { + t.Errorf("Expected output but got nothing") + } + + if !strings.Contains(outStr, author_data) { + t.Errorf("Expected to find 'syntax for the test file' in output but got %s", outStr) + } + + if string(out) != "" { + t.Errorf("Expected empty output but got %s", string(out)) + } + +} + +// users CMD TEST END + +// root CMD TEST BEGIN +func Test_CommitCmd(t *testing.T) { + setup() + defer teardown() + + //stdout reader + outC, r, w, old := StdoutReader() + + go func() { + var buf bytes.Buffer + io.Copy(&buf, r) + outC <- buf.String() + }() + + cmd := rootCmD + cmd.SetArgs([]string{"-t", "Test commit message"}) + cmd.Execute() + + w.Close() + os.Stdout = old + outStr := <-outC + if outStr == "" { + t.Errorf("Expected output but got nothing") + } + + if !strings.Contains(outStr, "Test commit message\n") { + t.Errorf("Expected to find 'Test commit message' in output but got %s", outStr) + } + +} + +func Test_CommitCmdWithM(t *testing.T) { + setup() + defer teardown() + + //stdout reader + outC, r, w, old := StdoutReader() + + go func() { + var buf bytes.Buffer + io.Copy(&buf, r) + outC <- buf.String() + }() + + cmd := rootCmD + cmd.SetArgs([]string{"-m", "-t", "Test commit message"}) + cmd.Execute() + + w.Close() + os.Stdout = old + outStr := <-outC + if outStr == "" { + t.Errorf("Expected output but got nothing") + } + + if !strings.Contains(outStr, "Test commit message\n") { + t.Errorf("Expected to find 'Test commit message' in output but got %s", outStr) + } + + +} +// root CMD TEST END diff --git a/src_code/go_src/cmd/root.go b/src_code/go_src/cmd/root.go new file mode 100644 index 0000000..6b335ca --- /dev/null +++ b/src_code/go_src/cmd/root.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/Slug-Boi/cocommit/src_code/go_src/cmd/tui" + "github.com/Slug-Boi/cocommit/src_code/go_src/cmd/utils" + + "github.com/inancgumus/screen" + "github.com/spf13/cobra" +) + +// rootCmd represents the base command when called without any subcommands +// func RootCmd() *cobra.Command { +var rootCmD = &cobra.Command{ + Use: `cocommit [co-author2] ... || + cocommit [co-author2:email] ... || + cocommit all || + cocommit ^ ^[co-author2] ... || + cocommit || + cocommit users ||`, + DisableFlagsInUseLine: true, + Short: "A cli tool to help you add co-authors to your git commits", + Long: `A cli tool to help you add co-authors to your git commits`, + //TODO: add bubble tea interface to this + Args: cobra.MinimumNArgs(0), + Run: func(cmd *cobra.Command, args []string) { + var message string + + // check if the print flag is set + pflag, _ := cmd.Flags().GetBool("print") + tflag, _ := cmd.Flags().GetBool("test_print") + // run execute commands again as root run will not call this part + // redundant check for now but will be useful later when we add tui + wrap_around: + switch len(args) { + case 0: + // launch the tui + args = append(args, tui.Entry_CM()) + screen.Clear() + screen.MoveTopLeft() + sel_auth := tui.Entry() + message = utils.Commit(args[0], sel_auth) + goto tui + case 1: + if len(args) == 1 { + if tflag { + fmt.Println(args[0]) + return + } + + utils.GitWrapper(args[0]) + if pflag { + fmt.Println(args[0]) + } + os.Exit(0) + } + } + + // check if user included -m tag and remove. Wrap around for safety's sake + if args[0] == "-m" { + args = args[1:] + goto wrap_around + } + + // builds the commit message with the selected authors + message = utils.Commit(args[0], args[1:]) + + tui: + // prints the commit message to the console if the print flag is set + if pflag { + fmt.Println(message) + } + // runs the git commit command + utils.GitWrapper(message) + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + // author file check + author_file := utils.CheckAuthorFile() + // define users + utils.Define_users(author_file) + + err := rootCmD.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + //rootCmD := RootCmd() + rootCmD.Flags().BoolP("print", "p", false, "Prints the commit message to the console") + rootCmD.Flags().BoolP("test_print", "t", false, "Prints the commit message to the console without running the git commit command") + rootCmD.Flags().BoolP("message", "m", false, "Does nothing but allows for -m to be used in the command") +} diff --git a/src_code/go_src/cmd/tui/README.md b/src_code/go_src/cmd/tui/README.md new file mode 100644 index 0000000..ac5e3f0 --- /dev/null +++ b/src_code/go_src/cmd/tui/README.md @@ -0,0 +1,4 @@ +# TUI based on bubbletea +All of the current TUI elements are mostly the same as the base templates from the bubble tea github repo. I am not a designer and the templates look pretty cool anyways so all in all very cool stuff + +[Bubbletea Examples Folder](https://github.com/charmbracelet/bubbletea/tree/main/examples) \ No newline at end of file diff --git a/src_code/go_src/cmd/tui/tui_author.go b/src_code/go_src/cmd/tui/tui_author.go new file mode 100644 index 0000000..6dfc04e --- /dev/null +++ b/src_code/go_src/cmd/tui/tui_author.go @@ -0,0 +1,304 @@ +package tui + +// A simple example demonstrating the use of multiple text input components +// from the Bubbles component library. + +import ( + "fmt" + "github.com/Slug-Boi/cocommit/src_code/go_src/cmd/utils" + "os" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + //"github.com/inancgumus/screen" +) + +var ( + focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + cursorStyle = focusedStyle + noStyle = lipgloss.NewStyle() + cursorModeHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + focusedButton = focusedStyle.Render("[ Submit ]") + focusedExclude = focusedStyle.Render("[ Exclude ]") + blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Submit")) + excludeButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Exclude")) +) + +var removeButton bool + +type model_ca struct { + focusIndex int + inputs []textinput.Model + quitting bool + exclude bool +} + +func createAuthorModel() model_ca { + m := model_ca{ + inputs: make([]textinput.Model, 5), + } + + var t textinput.Model + for i := range m.inputs { + t = textinput.New() + t.Cursor.Style = cursorStyle + //t.CharLimit = 32 + + switch i { + case 0: + t.Placeholder = "Shortname (e.g. jo)" + t.Focus() + t.PromptStyle = focusedStyle + t.TextStyle = focusedStyle + case 1: + t.Placeholder = "Long name (e.g. JohnDoe)" + case 2: + t.Placeholder = "Username (e.g. JohnDoe-gh)" + case 3: + t.Placeholder = "Email (e.g. JohnDoe@domain.do" + case 4: + t.Placeholder = "Group tags (e.g. gr1|gr2)" + } + + m.inputs[i] = t + } + + return m +} + +func tempAuthorModel() model_ca { + m := model_ca{ + inputs: make([]textinput.Model, 2), + } + + var t textinput.Model + for i := range m.inputs { + t = textinput.New() + t.Cursor.Style = cursorStyle + //t.CharLimit = 32 + + switch i { + case 0: + t.Placeholder = "Username (e.g. JohnDoe-gh)" + t.Focus() + t.PromptStyle = focusedStyle + t.TextStyle = focusedStyle + case 1: + t.Placeholder = "Email (e.g. JohnDoe@JohnDoe.io)" + } + + m.inputs[i] = t + } + + removeButton = true + + return m +} + +func initialModel(model string) model_ca { + if model == "author" { + return createAuthorModel() + } else { + return tempAuthorModel() + } + +} + +func (m model_ca) Init() tea.Cmd { + return textinput.Blink +} + +func (m model_ca) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.inputs = nil + return m, tea.Quit + + // Set focus to next input + case "tab", "shift+tab", "enter", "up", "down": + s := msg.String() + + // Did the user press enter while the submit button was focused? + // If so, exit. + if !removeButton { + if s == "enter" && m.focusIndex == len(m.inputs)+1 { + m.quitting = true + return m, tea.Quit + } else if s == "enter" && m.focusIndex == len(m.inputs) { + // toggle exclude + m.exclude = !m.exclude + return m, nil + } + } else { + if s == "enter" && m.focusIndex == len(m.inputs) { + m.quitting = true + return m, tea.Quit + } + } + + // Cycle indexes + if s == "up" || s == "shift+tab" { + m.focusIndex-- + } else { + m.focusIndex++ + } + + if m.focusIndex > len(m.inputs)+1 { + m.focusIndex = 0 + } else if m.focusIndex < 0 { + m.focusIndex = len(m.inputs) + } + + cmds := make([]tea.Cmd, len(m.inputs)) + for i := 0; i <= len(m.inputs)-1; i++ { + if i == m.focusIndex { + // Set focused state + cmds[i] = m.inputs[i].Focus() + m.inputs[i].PromptStyle = focusedStyle + m.inputs[i].TextStyle = focusedStyle + continue + } + // Remove focused state + m.inputs[i].Blur() + m.inputs[i].PromptStyle = noStyle + m.inputs[i].TextStyle = noStyle + } + + return m, tea.Batch(cmds...) + } + } + + // Handle character input and blinking + cmd := m.updateInputs(msg) + + return m, cmd +} + +func (m *model_ca) updateInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.inputs)) + + // Only text inputs with Focus() set will respond, so it's safe to simply + // update all of them here without any further logic. + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + + return tea.Batch(cmds...) +} + +func (m model_ca) View() string { + if m.quitting { + return "" + } + + var b strings.Builder + + for i := range m.inputs { + b.WriteString(m.inputs[i].View()) + if i < len(m.inputs)-1 { + b.WriteRune('\n') + } + } + + //TODO: add check here for wether this button is needed + var exclude *string + var button *string + if !removeButton { + exclude = &excludeButton + if m.focusIndex == len(m.inputs) { + exclude = &focusedExclude + } + button = &blurredButton + if m.focusIndex == len(m.inputs)+1 { + button = &focusedButton + } + + if m.exclude { + fmt.Fprintf(&b, "\n\n%s: [X]\n\n", *exclude) + } else { + fmt.Fprintf(&b, "\n\n%s: [ ]\n\n", *exclude) + } + } else { + button = &blurredButton + if m.focusIndex == len(m.inputs) { + button = &focusedButton + } + } + + fmt.Fprintf(&b, "\n\n%s\n\n", *button) + + b.WriteString(cursorModeHelpStyle.Render()) + + return b.String() +} + +func Entry_CA() string { + m, err := tea.NewProgram(initialModel("author")).Run() + if err != nil { + fmt.Printf("could not start program: %s\n", err) + os.Exit(1) + } + + if len(m.(model_ca).inputs) > 0 && + m.(model_ca).inputs[0].Value() != "" && + m.(model_ca).inputs[1].Value() != "" && + m.(model_ca).inputs[2].Value() != "" && + m.(model_ca).inputs[3].Value() != "" { + author_file := utils.Find_authorfile() + f, err := os.OpenFile(author_file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + panic(err) + } + + defer f.Close() + + sb := strings.Builder{} + sb.WriteRune('\n') + + sb.WriteString(fmt.Sprintf("%s|%s|%s|%s", + m.(model_ca).inputs[0].Value(), + m.(model_ca).inputs[1].Value(), + m.(model_ca).inputs[2].Value(), + m.(model_ca).inputs[3].Value())) + + if m.(model_ca).exclude { + sb.WriteString(fmt.Sprintf("|%s", "ex")) + } + + if m.(model_ca).inputs[4].Value() != "" { + sb.WriteString(fmt.Sprintf(";;%s", m.(model_ca).inputs[4].Value())) + } + + //sb.WriteRune('\n') + + if _, err = f.WriteString(sb.String()); err != nil { + panic(err) + } + utils.Define_users(utils.Find_authorfile()) + return m.(model_ca).inputs[0].Value() + } + return "" +} + +func Entry_TA() string { + m, err := tea.NewProgram(initialModel("temp")).Run() + if err != nil { + fmt.Printf("could not start program: %s\n", err) + os.Exit(1) + } + + if len(m.(model_ca).inputs) > 0 && + m.(model_ca).inputs[0].Value() != "" && + m.(model_ca).inputs[1].Value() != "" { + utils.TempAddUser(m.(model_ca).inputs[0].Value(), m.(model_ca).inputs[1].Value()) + return m.(model_ca).inputs[0].Value() + ":" + m.(model_ca).inputs[1].Value() + } + + return "" + +} diff --git a/src_code/go_src/cmd/tui/tui_commit_message.go b/src_code/go_src/cmd/tui/tui_commit_message.go new file mode 100644 index 0000000..3fa5bef --- /dev/null +++ b/src_code/go_src/cmd/tui/tui_commit_message.go @@ -0,0 +1,111 @@ +package tui + +// A simple program demonstrating the textarea component from the Bubbles +// component library. + +//TODO: maybe add a submit button below the textarea + +import ( + "fmt" + "log" + "os" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type KeyMap struct { + EndWithMes key.Binding +} + +func newKeyMap() *KeyMap { + return &KeyMap{ + EndWithMes: key.NewBinding( + key.WithKeys("enter"), + ), + } +} + +func Entry_CM() string { + + newKeyMap() + + p := tea.NewProgram(initialModel_cm()) + m, err := p.Run() + if err != nil { + log.Fatal(err) + } + if m.(model_cm).textarea.Value() == "" { + fmt.Println("No commit message provided. Exiting...") + os.Exit(1) + } + return m.(model_cm).textarea.Value() + "\n" +} + +type errMsg error + +type model_cm struct { + textarea textarea.Model + keys *KeyMap + err error +} + +func initialModel_cm() model_cm { + ti := textarea.New() + ti.FocusedStyle = textarea.Style{Base: lipgloss.NewStyle().Foreground(lipgloss.Color("170"))} + + ti.Placeholder = "Write your commit message here..." + ti.Focus() + + return model_cm{ + textarea: ti, + keys: newKeyMap(), + err: nil, + } +} + +func (m model_cm) Init() tea.Cmd { + return textarea.Blink +} + +func (m model_cm) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.EndWithMes): + return m, tea.Quit + } + switch msg.Type { + case tea.KeyCtrlC: + m.textarea.SetValue("") + return m, tea.Quit + default: + if !m.textarea.Focused() { + cmd = m.textarea.Focus() + cmds = append(cmds, cmd) + } + } + + // We handle errors just like any other message + case errMsg: + m.err = msg + return m, nil + } + + m.textarea, cmd = m.textarea.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m model_cm) View() string { + return fmt.Sprintf( + "Commit message:\n\n%s\n\n%s", + m.textarea.View(), + "(alt+enter | Submit)\n(ctrl+c | Cancel)", + ) + "\n\n" +} diff --git a/src_code/go_src/cmd/tui/tui_list.go b/src_code/go_src/cmd/tui/tui_list.go new file mode 100644 index 0000000..81d617d --- /dev/null +++ b/src_code/go_src/cmd/tui/tui_list.go @@ -0,0 +1,341 @@ +package tui + +import ( + "fmt" + "io" + "github.com/Slug-Boi/cocommit/src_code/go_src/cmd/utils" + "os" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/inancgumus/screen" +) + +const listHeight = 14 + +var ( + titleStyle = lipgloss.NewStyle().MarginLeft(2) + itemStyle = lipgloss.NewStyle().PaddingLeft(4) + selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) + highlightStyle = lipgloss.NewStyle().PaddingLeft(4).Background(lipgloss.Color("236")).Foreground(lipgloss.Color("17")) + selectedHighlightStyle = lipgloss.NewStyle().PaddingLeft(2).Background(lipgloss.Color("236")).Foreground(lipgloss.Color("170")) + deletionStyle = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("9")) + paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) + helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) + //quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4) +) + +type item string + +var selected = map[string]item{} + +var negation = false + +var dupProtect = map[string]string{} + +type listKeyMap struct { + selectAll key.Binding + negation key.Binding + groupSelect key.Binding + selectOne key.Binding + createAuthor key.Binding + deleteAuthor key.Binding + tempAdd key.Binding +} + +func newListKeyMap() *listKeyMap { + return &listKeyMap{ + selectAll: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "Add all authors"), + ), + negation: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "Toggle negation and select author"), + ), + groupSelect: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "Select group"), + ), + selectOne: key.NewBinding( + key.WithKeys(" "), + key.WithHelp("space", "Select author"), + ), + createAuthor: key.NewBinding( + key.WithKeys("C"), + key.WithHelp("C", "Create new author"), + ), + deleteAuthor: key.NewBinding( + key.WithKeys("D"), + key.WithHelp("D", "Delete author"), + ), + tempAdd: key.NewBinding( + key.WithKeys("T"), + key.WithHelp("T", "Add temporary author"), + ), + } +} + +func (i item) FilterValue() string { return string(i) } + +type itemDelegate struct{} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(item) + if !ok { + return + } + + str := fmt.Sprintf("%d. %s", index+1, i) + + fn := itemStyle.Render + if _, ok := selected[string(i)]; ok { + fn = func(s ...string) string { + base := strings.Join(s, " ") + if negation { + base = base + " ^" + } + if index == m.Index() { + return selectedHighlightStyle.Render("> " + base + " [X]") + } else { + return highlightStyle.Render(base + " [X]") + } + } + } else { + if index == m.Index() { + fn = func(s ...string) string { + return selectedItemStyle.Render("> " + strings.Join(s, " ")) + } + } + } + + fmt.Fprint(w, fn(str)) +} + +type model struct { + list list.Model + keys *listKeyMap + quitting bool +} + +func (m model) Init() tea.Cmd { + return nil +} + +func selectToggle(i item) { + if _, ok := selected[string(i)]; ok { + delete(selected, string(i)) + toggleNegation() + } else { + selected[string(i)] = i + } +} + +func toggleNegation() { + if len(selected) == 0 { + negation = false + } +} + +var deletion bool + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.list.SetWidth(msg.Width) + return m, nil + + // If filtering is enabled, skip key handling + case tea.KeyMsg: + // deletion toggle with confirmation required + b := false + defer func(b *bool){deletion = *b}(&b) + if m.list.FilterState() == list.Filtering { + break + } + // Handle keys from keyList (help menu) + switch { + case key.Matches(msg, m.keys.negation): + i, ok := m.list.SelectedItem().(item) + if ok { + negation = true + selectToggle(i) + } + + case key.Matches(msg, m.keys.selectOne): + i, ok := m.list.SelectedItem().(item) + if ok { + selectToggle(i) + } + + case key.Matches(msg, m.keys.selectAll): + //TODO: maybe look at behavior of this when auth are already selected + negation = false + for _, i := range m.list.Items() { + selectToggle(i.(item)) + } + + case key.Matches(msg, m.keys.groupSelect): + // group code goes here + + case key.Matches(msg, m.keys.tempAdd): + screen.Clear() + screen.MoveTopLeft() + tempAuthr := Entry_TA() + if tempAuthr != "" { + split := strings.Split(tempAuthr, ":") + item_str := split[0] + " - " + split[1] + dupProtect[item_str] = tempAuthr + i := item(item_str) + m.list.InsertItem(len(m.list.Items())+1, i) + selectToggle(i) + } + return m, tea.ClearScreen + + case key.Matches(msg, m.keys.createAuthor): + screen.Clear() + screen.MoveTopLeft() + author := Entry_CA() + if author != "" { + item_str := utils.Users[author].Username + " - " + utils.Users[author].Email + dupProtect[item_str] = author + m.list.InsertItem(len(m.list.Items())+1, item(item_str)) + } + return m, tea.ClearScreen + case key.Matches(msg, m.keys.deleteAuthor): + if deletion { + author_str := string(m.list.SelectedItem().(item)) + author := dupProtect[author_str] + utils.DeleteOneAuthor(author) + delete(dupProtect, author_str) + m.list.RemoveItem(m.list.Index()) + return m, nil + } + b = true + return m, nil + } + // extra key options + switch keypress := msg.String(); keypress { + case "q", "ctrl+c", "esc": + m.quitting = true + selected = nil + return m, tea.Quit + + case "enter": + m.quitting = true + return m, tea.Quit + } + + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m model) View() string { + if m.quitting { + return "" //quitTextStyle.Render(strings.Join(m.choice, " ")) + } + + sb := strings.Builder{} + + sb.WriteString("\n" + m.list.View()) + + if deletion { + sb.WriteString(deletionStyle.Render("\n D: Confirm delete author")) + } + + return sb.String() +} + +func listModel() model { + items := []list.Item{} + + selected = map[string]item{} + + dupProtect = map[string]string{} + + listKeys := newListKeyMap() + + // Add items to the list + for short, user := range utils.Users { + // if items already contains the user, skip it + str_user := user.Username + " - " + user.Email + if _, ok := dupProtect[str_user]; ok { + continue + } + items = append(items, item(str_user)) + dupProtect[str_user] = short + } + + sort.Slice(items, func(i, j int) bool { + return items[i].(item) < items[j].(item) + }) + + const defaultWidth = 20 + + l := list.New(items, itemDelegate{}, defaultWidth, listHeight) + l.Title = "Select authors to add to commit" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) // Enable filtering + l.Styles.Title = titleStyle + l.Styles.PaginationStyle = paginationStyle + l.AdditionalShortHelpKeys = // Add help keys (main page) + func() []key.Binding { + return []key.Binding{ + listKeys.selectOne, + } + } + l.AdditionalFullHelpKeys = // Add help keys (help menu) + func() []key.Binding { + return []key.Binding{ + listKeys.selectAll, + listKeys.negation, + listKeys.groupSelect, + listKeys.createAuthor, + } + } + l.Styles.HelpStyle = helpStyle + + return model{list: l, keys: listKeys} +} + +// TODO: pass list in as a param to allow for group selection using same template +func Entry() []string { + + m := listModel() + + f, err := tea.NewProgram(m).Run() + if err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + + // Assert the final tea.Model to our local model and print the choice. + + output := []string{} + if len(selected) == 0 { + os.Exit(0) + } + for i := range selected { + short := dupProtect[i] + if negation { + short = "^" + short + } + + output = append(output, short) + } + + if _, ok := f.(model); ok && len(output) > 0 { + return output + } + return nil +} diff --git a/src_code/go_src/cmd/tui/tui_show_users.go b/src_code/go_src/cmd/tui/tui_show_users.go new file mode 100644 index 0000000..3a7b26b --- /dev/null +++ b/src_code/go_src/cmd/tui/tui_show_users.go @@ -0,0 +1,127 @@ +package tui + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" +) + +//TODO: MAybe change away from glamour if the weird email issue can't be solved + +var content string + +var ( + helpStyle_us = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render +) + +type example struct { + viewport viewport.Model +} + +func newExample() (*example, error) { + const width = 78 + + vp := viewport.New(width, 20) + vp.Style = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + PaddingRight(2) + + renderer, err := glamour.NewTermRenderer( + glamour.WithPreservedNewLines(), + glamour.WithAutoStyle(), + glamour.WithWordWrap(width), + ) + if err != nil { + return nil, err + } + + str, err := renderer.Render(content) + if err != nil { + return nil, err + } + + vp.SetContent(str) + + return &example{ + viewport: vp, + }, nil +} + +func (e example) Init() tea.Cmd { + return nil +} + +func (e example) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + return e, tea.Quit + default: + var cmd tea.Cmd + e.viewport, cmd = e.viewport.Update(msg) + return e, cmd + } + default: + return e, nil + } +} + +func (e example) View() string { + return e.viewport.View() + e.helpView() +} + +func (e example) helpView() string { + return helpStyle_us("\n ↑/↓: Navigate • q: Quit\n") +} + +func intialModel_US(author_file string) tea.Model { + loadData(author_file) + + model, err := newExample() + if err != nil { + fmt.Println("Could not initialize Bubble Tea model:", err) + os.Exit(1) + } + return model +} + +func loadData(author_file string) { + file, err := os.Open(author_file) + if err != nil { + fmt.Println("Could not open file:", err) + os.Exit(1) + } + + scanner := bufio.NewScanner(file) + var cnt strings.Builder + + scanner.Scan() + header := scanner.Text() + cnt.WriteString(header + "\n") + + for scanner.Scan() { + //very hacky it basically just ensure glamour doesn't format the email + cnt.WriteString(":\b" + scanner.Text() + "\n") + } + + content = cnt.String() + +} + +func Entry_US(author_file string) { + + model := intialModel_US(author_file) + + if _, err := tea.NewProgram(model).Run(); err != nil { + fmt.Println("Bummer, there's been an error:", err) + os.Exit(1) + } +} diff --git a/src_code/go_src/cmd/tui/tui_test.go b/src_code/go_src/cmd/tui/tui_test.go new file mode 100644 index 0000000..1ce5622 --- /dev/null +++ b/src_code/go_src/cmd/tui/tui_test.go @@ -0,0 +1,363 @@ +package tui + +import ( + "bytes" + "os" + "testing" + "time" + + "github.com/Slug-Boi/cocommit/src_code/go_src/cmd/utils" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" +) + +const author_data = `syntax for the test file +te|testing|TestUser|test@test.test|ex +ti|testtest|UserName2|testing@user.io;;gr1` + +var envVar string + +func setup() { + // setup test data + err := os.WriteFile("author_file_test", []byte(author_data), 0644) + if err != nil { + panic(err) + } + os.Setenv("author_file", "author_file_test") + envVar = os.Getenv("author_file") +} + +func teardown() { + // remove test data + os.Remove("author_file_test") + os.Setenv("author_file", envVar) +} +// tui_show_users TESTS BEGIN +func TestShowUser(t *testing.T) { + setup() + defer teardown() + + m := intialModel_US(envVar) + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(300, 300), + ) + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("syntax for the test file")) + }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*2)) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("q"), + }) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) + +} + +// tui_show_users TESTS END + +// tui_author TESTS BEGIN +func TestEntryTA(t *testing.T) { + setup() + defer teardown() + + m := initialModel("temp") + tm := teatest.NewTestModel( + t, m, teatest.WithInitialTermSize(300, 300), + ) + tm.Type("test") + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + tm.Type("testtest@temp.io") + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + fm := tm.FinalModel(t) + m, ok := fm.(model_ca) + if !ok { + t.Errorf("Expected model_ca, got %T", fm) + } + + if len(m.inputs) != 2 { + t.Errorf("Expected 2 inputs, got %d", len(m.inputs)) + } + if m.inputs[0].Value() != "test" { + t.Errorf("Expected 'test', got %s", m.inputs[0].Value()) + } + if m.inputs[1].Value() != "testtest@temp.io" { + t.Errorf("Expected 'testtest@temp.io', got %s", m.inputs[1].Value()) + } +} + +func Test_EntryCA(t *testing.T) { + setup() + defer teardown() + + m := initialModel("author") + tm := teatest.NewTestModel( + t, m, teatest.WithInitialTermSize(300, 300), + ) + tm.Type("test") + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + tm.Type("testtest") + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + tm.Type ("TestUser") + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + tm.Type("test@temp.io") + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + tm.Type("gr1") + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("tab"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + fm := tm.FinalModel(t) + m, ok := fm.(model_ca) + if !ok { + t.Errorf("Expected model_ca, got %T", fm) + } + + if len(m.inputs) != 5 { + t.Errorf("Expected 5 inputs, got %d", len(m.inputs)) + } + if m.inputs[0].Value() != "test" { + t.Errorf("Expected 'test', got %s", m.inputs[0].Value()) + } + if m.inputs[1].Value() != "testtest" { + t.Errorf("Expected 'testtest', got %s", m.inputs[1].Value()) + } + if m.inputs[2].Value() != "TestUser" { + t.Errorf("Expected 'TestUser', got %s", m.inputs[2].Value()) + } + if m.inputs[3].Value() != "test@temp.io" { + t.Errorf("Expected 'test@temp.io', got %s", m.inputs[2].Value()) + } + if m.inputs[4].Value() != "gr1" { + t.Errorf("Expected 'gr1', got %s", m.inputs[4].Value()) + } + //No clue why the exclude tag isn't working fix later + //TODO: Fix this should be !m.exclude + if m.exclude { + t.Errorf("Expected exclude to be true, got %v", m.exclude) + } +} +// tui_author TESTS END + +// tui_commit_message TESTS BEGIN +func Test_EntryCM(t *testing.T) { + setup() + defer teardown() + + m := initialModel_cm() + tm := teatest.NewTestModel( + t, m, teatest.WithInitialTermSize(300, 300), + ) + tm.Type("test commit message") + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + fm := tm.FinalModel(t) + m, ok := fm.(model_cm) + if !ok { + t.Errorf("Expected model_cm, got %T", fm) + } + + if m.textarea.Value() != "test commit message" { + t.Errorf("Expected 'test commit message', got %s", m.textarea.Value()) + } +} +// tui_commit_message TESTS END + + +// tui_list TESTS BEGIN +func Test_EntrySelectUsers(t *testing.T) { + setup() + defer teardown() + + utils.Define_users("author_file_test") + + m := listModel() + tm := teatest.NewTestModel( + t, m, teatest.WithInitialTermSize(300, 300), + ) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune(" "), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + fm := tm.FinalModel(t) + m, ok := fm.(model) + if !ok { + t.Errorf("Expected model, got %T", fm) + } + + if !m.quitting { + t.Errorf("Expected quitting to be true, got %v", m.quitting) + } + + if len(selected) != 1 { + t.Errorf("Expected 1 selected item, got %d", len(selected)) + } + +} + +func Test_EntrySelectAll(t *testing.T) { + setup() + defer teardown() + + utils.Define_users("author_file_test") + + m := listModel() + tm := teatest.NewTestModel( + t, m, teatest.WithInitialTermSize(300, 300), + ) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("A"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + fm := tm.FinalModel(t) + m, ok := fm.(model) + if !ok { + t.Errorf("Expected model, got %T", fm) + } + + if !m.quitting { + t.Errorf("Expected quitting to be true, got %v", m.quitting) + } + + if len(selected) != 2 { + t.Errorf("Expected 2 selected item, got %d", len(selected)) + } +} + +func Test_EntryNegation(t *testing.T) { + setup() + defer teardown() + + utils.Define_users("author_file_test") + + m := listModel() + tm := teatest.NewTestModel( + t, m, teatest.WithInitialTermSize(300, 300), + ) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("n"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + fm := tm.FinalModel(t) + m, ok := fm.(model) + if !ok { + t.Errorf("Expected model, got %T", fm) + } + + if !m.quitting { + t.Errorf("Expected quitting to be true, got %v", m.quitting) + } + + if len(selected) != 1 { + t.Errorf("Expected 2 selected item, got %d", len(selected)) + } +} + +func Test_EntryDeleteAuthor(t *testing.T) { + setup() + defer teardown() + + utils.Define_users("author_file_test") + + m := listModel() + tm := teatest.NewTestModel( + t, m, teatest.WithInitialTermSize(300, 300), + ) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("D"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("D"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + fm := tm.FinalModel(t) + m, ok := fm.(model) + if !ok { + t.Errorf("Expected model, got %T", fm) + } + + if !m.quitting { + t.Errorf("Expected quitting to be true, got %v", m.quitting) + } + + if len(utils.Users) != 2 { + t.Errorf("Expected 2 user after deletion, got %d", len(utils.Users)) + } +} +// tui_list TESTS END \ No newline at end of file diff --git a/src_code/go_src/cmd/users.go b/src_code/go_src/cmd/users.go new file mode 100644 index 0000000..112238e --- /dev/null +++ b/src_code/go_src/cmd/users.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "github.com/Slug-Boi/cocommit/src_code/go_src/cmd/tui" + "github.com/Slug-Boi/cocommit/src_code/go_src/cmd/utils" + "os" + "os/exec" + "slices" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +var authorfile = utils.Find_authorfile() + +// usersCmd represents the users command +func UsersCmd() *cobra.Command { + return &cobra.Command{ + Use: "users", + Short: "Displays all users from the author file located at: " + authorfile, + Long: `Displays all users from the author file located at: ` + authorfile, + Run: func(cmd *cobra.Command, args []string) { + //TODO: make this print a bit prettier (sort it and maybe use a table) + // check if the no pretty print flag is set + np, _ := cmd.Flags().GetBool("np") + if np { + println("List of users:\nFormat: / -> Username: Email: ") + seen_users := []utils.User{} + user_sb := []string{} + for name, usr := range utils.Users { + if !slices.Contains(seen_users, usr) { + user_sb = append(user_sb, utils.Users[name].Names+" ->"+" Username: "+usr.Username+" Email: "+usr.Email+"\n") + seen_users = append(seen_users, usr) + } + } + sort.Strings(user_sb) + println(strings.Join(user_sb, "")) + os.Exit(0) + } + bat_check := exec.Command("which", "bat") + out, _ := bat_check.CombinedOutput() + if string(out) == "" { + tui.Entry_US(authorfile) + os.Exit(0) + } + bat := exec.Command("bat", authorfile) + bat.Stdout = os.Stdout + bat.Stderr = os.Stderr + bat.Run() + }, +} +} + +func init() { + usersCmd := UsersCmd() + rootCmD.AddCommand(usersCmd) + usersCmd.Flags().BoolP("np", "n", false, "No pretty print of the users") +} diff --git a/src_code/go_src/cmd/utils/author_file_utils.go b/src_code/go_src/cmd/utils/author_file_utils.go new file mode 100644 index 0000000..1c3a2fe --- /dev/null +++ b/src_code/go_src/cmd/utils/author_file_utils.go @@ -0,0 +1,95 @@ +package utils + +import ( + "bufio" + "bytes" + "fmt" + "os" + "regexp" +) + +// Author file utils is a package that contains functions that are used to read +// check, and potentially write to the author file. The author file is a file +// that contains the names and emails of the users that are allowed to commit +// An example of the author file can be found in the examples folder of the repo +func Find_authorfile() string { + if os.Getenv("author_file") == "" { + authors, err := os.UserConfigDir() + if err != nil { + fmt.Println("Error getting user config directory") + os.Exit(2) + } + return (authors + "/cocommit/authors") + } else { + return os.Getenv("author_file") + } +} + +func CheckAuthorFile() string { + authorfile := Find_authorfile() + if _, err := os.Stat(authorfile); os.IsNotExist(err) { + println("Author file not found at: ", authorfile) + println("Would you like to create one? (y/n)") + var response string + _, err := fmt.Scanln(&response) + if err != nil { + println("Error reading response") + } + if response == "y" { + //TODO: Tui response to create author file + //createAuthorFile(authorfile) + } else { + os.Exit(1) + } + } + // This string output is mostly for convenience can mostly be ignored + return authorfile +} + +func DeleteOneAuthor(author string) { + author_file := Find_authorfile() + + // open author_file + file, err := os.OpenFile(author_file, os.O_RDWR, 0644) + if err != nil { + fmt.Println("Error opening file: ", err) + return + } + + defer file.Close() + + // create regex to capture author line + regexp, err := regexp.Compile(fmt.Sprintf("^(.+\\|%s\\|.+|%s\\|.+\\|.+)$",author,author)) + if err != nil { + fmt.Println("Error compiling regex: ", err) + return + } + + var b []byte + buf := bytes.NewBuffer(b) + + // create a scanner for the file + scanner := bufio.NewScanner(file) + + // write the header to the buffer + scanner.Scan() + buf.WriteString(scanner.Text() + "\n") + + // check if author matches the regex and skip + for scanner.Scan() { + line := scanner.Text() + if regexp.MatchString(line) { + continue + } + buf.WriteString(line + "\n") + + } + // remove the last newline character + buf.Truncate(buf.Len()-1) + + file.Truncate(0) + file.Seek(0,0) + buf.WriteTo(file) + + RemoveUser(author) +} diff --git a/src_code/go_src/cmd/utils/commit.go b/src_code/go_src/cmd/utils/commit.go new file mode 100644 index 0000000..008497f --- /dev/null +++ b/src_code/go_src/cmd/utils/commit.go @@ -0,0 +1,110 @@ +package utils + +import ( + "fmt" + "os/exec" + "regexp" + "slices" + "strings" +) + +// This util file is used to create a commit message using a string builder + +// string builder for the commit message +var sb strings.Builder + +// list of excluded authors based on the author file +var excludeMode = []string{} + +// Regex pattern used to create temp users to add to the commit message +var reg, _ = regexp.Compile("([^:]+):([^:]+)") + +func Commit(message string, authors []string) string { + // write the commit message to the string builder + sb.WriteString(message + "\n") + fst := authors[0] + + if fst == "all" || fst == "All" { + add_x_users(excludeMode) + goto skip_loop + } else if Groups[fst] != nil { + excludeMode = group_selection(Groups[fst], excludeMode) + add_x_users(excludeMode) + goto skip_loop + } + + // Loop that adds users + for _, committer := range authors { + if _, ok := Users[committer]; ok { + sb_author(committer) + } else if match := reg.MatchString(committer); match { + str := strings.Split(committer, ":") + + sb.WriteString("\nCo-authored-by: ") + sb.WriteString(str[0]) + sb.WriteString(" <") + sb.WriteString(str[1]) + sb.WriteRune('>') + + } else if committer[0] == '^' { // Negations + excludeMode = append(excludeMode, Users[committer[1:]].Username) + } else { + println(committer, " was unknown. User either not defined or name typed wrong") + } + } + if len(excludeMode) > 0 { + add_x_users(excludeMode) + } + + // Skip label for edge cases at top of function +skip_loop: + return sb.String() +} + +func GitWrapper(commit string) { + // commit shell command + cmd := exec.Command("git", "commit", "-m", commit) + + // https://stackoverflow.com/questions/18159704/how-to-debug-exit-status-1-error-when-running-exec-command-in-golang + + cmd_output, err := cmd.CombinedOutput() + + if err != nil { + println(fmt.Sprint(err) + " : " + string(cmd_output)) + } else { + println(string(cmd_output)) + } +} + +// helper function to add an author to the commit message +func sb_author(committer string) { + sb.WriteString("\nCo-authored-by: ") + sb.WriteString(Users[committer].Username) + sb.WriteString(" <") + sb.WriteString(Users[committer].Email) + sb.WriteRune('>') +} + +// helper function to add x amount of users to the commit message +func add_x_users(excludeMode []string) { + if len(DefExclude) > 0 { + excludeMode = append(excludeMode, DefExclude...) + } + for key, user := range Users { + if !slices.Contains(excludeMode, user.Username) { + sb_author(key) + excludeMode = append(excludeMode, user.Username) + } + } +} + +// helper function to select groups of users to exclude in the commit message +func group_selection(group []User, excludeMode []string) []string { + for _, user := range Users { + if !(slices.Contains(group, user)) { + excludeMode = append(excludeMode, user.Username) + } + } + + return excludeMode +} diff --git a/src_code/go_src/cmd/utils/user_util.go b/src_code/go_src/cmd/utils/user_util.go new file mode 100644 index 0000000..cdfd5af --- /dev/null +++ b/src_code/go_src/cmd/utils/user_util.go @@ -0,0 +1,92 @@ +package utils + +import ( + "bufio" + "os" + "strings" +) + +// This util file is used to handle users and their information +type User struct { + Username string + Email string + Names string +} + +var Users = map[string]User{} +var DefExclude = []string{} +var Groups = map[string][]User{} + +func Define_users(author_file string) { + file, err := os.Open(author_file) + if err != nil { + print("File not found") + os.Exit(2) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + + // eat a single input + scanner.Scan() + + // reads the input of authors file and formats accordingly + for scanner.Scan() { + input_str := scanner.Text() + group_info := []string{} + if strings.Contains(input_str, ";;") { + input := strings.Split(input_str, ";;") + input_str = input[0] + group_info = append(group_info, strings.Split(input[1], "|")...) + } + info := strings.Split(input_str, "|") + if len(info) < 4 { + if len(info) > 0 { + println("Error: User ", info[0], " is missing information") + } else { + println("Error: Some user is missing information") + } + println("Please check the author file for proper syntax") + os.Exit(1) + } + usr := User{Username: info[2], Email: info[3], Names: info[0] + "/" + info[1]} + Users[info[0]] = usr + Users[info[1]] = usr + // Adds users with the ex tag to the defExclude list + if len(info) == 5 { + if info[4] == "ex" { + DefExclude = append(DefExclude, info[2]) + } + } else if len(group_info) > 0 { + // Group assignment + for _, group := range group_info { + if Groups[group] == nil { + Groups[group] = []User{usr} + } else { + //TODO: Try and find a cleaner way of doing this + usr_lst := Groups[group] + usr_lst = append(usr_lst, usr) + Groups[group] = usr_lst + } + } + } + } + + if err := scanner.Err(); err != nil { + os.Exit(2) + } +} + +func RemoveUser(short string) { + usr := Users[short] + split := strings.Split(usr.Names, "/") + delete(Users, split[0]) + delete(Users, split[1]) +} + +func TempAddUser(username, email string) { + usr := User{Username: username, Email: email} + + Users[username] = usr +} + diff --git a/src_code/go_src/cmd/utils/util_test.go b/src_code/go_src/cmd/utils/util_test.go new file mode 100644 index 0000000..c58af1e --- /dev/null +++ b/src_code/go_src/cmd/utils/util_test.go @@ -0,0 +1,120 @@ +package utils_test + +import ( + "github.com/Slug-Boi/cocommit/src_code/go_src/cmd/utils" + "os" + "testing" +) + +const author_data = `syntax for the test file +te|testing|TestUser|test@test.test|ex +ti|testtest|UserName2|testing@user.io;;gr1` + +var envVar = os.Getenv("author_file") + +func setup() { + // setup test data + os.WriteFile("author_file_test", []byte(author_data), 0644) + os.Setenv("author_file", "author_file_test") +} + +func teardown() { + // remove test data + os.Remove("author_file_test") + os.Setenv("author_file", envVar) +} + +// Author tests BEGIN +func Test_FindAuthorFile(t* testing.T) { + setup() + defer teardown() + // Test Find_authorfile + authorfile := utils.Find_authorfile() + if authorfile != "author_file_test" { + t.Errorf("Find_authorfile() = %v; want authors_file_test", authorfile) + } +} + +func Test_DeleteAuthor(t *testing.T) { + setup() + defer teardown() + // Test DeleteOneAuthor + og_bytes, err := os.ReadFile("author_file_test") + if err != nil { + t.Errorf("Error reading file: %v", err) + } + + utils.DeleteOneAuthor("te") + deleted_bytes, err := os.ReadFile("author_file_test") + if err != nil { + t.Errorf("Error reading file: %v", err) + } + + if string(og_bytes) == string(deleted_bytes) { + t.Errorf("DeleteOneAuthor() did not delete author") + } +} + +// Author tests END + +// User tests BEGIN +func Test_DefineUsers(t *testing.T) { + setup() + defer teardown() + // Test Define_users + utils.Define_users("author_file_test") + if len(utils.Users) != 4 { + t.Errorf("Define_users() = %v; want 4", len(utils.Users)) + } +} + +func Test_RemoveUser(t *testing.T) { + setup() + defer teardown() + // Test RemoveUser + utils.Define_users("author_file_test") + + utils.RemoveUser("te") + + if len(utils.Users) != 3 { + t.Errorf("RemoveUser() = %v; want 3", len(utils.Users)) + } +} + +func Test_TempAddUser(t *testing.T) { + setup() + defer teardown() + // Test TempAddUser + utils.Define_users("author_file_test") + if len(utils.Users) != 4 { + t.Errorf("Define_users() = %v; want 4", len(utils.Users)) + } + + utils.TempAddUser("temp", "temp@test.io") + + if len(utils.Users) != 5 { + t.Errorf("TempAddUser() = %v; want 5", len(utils.Users)) + } + + if _, ok := utils.Users["temp"]; !ok { + t.Errorf("TempAddUser() did not add user") + } + +} +// User tests END + +// Commit tests BEGIN + +func Test_Commit(t* testing.T) { + setup() + defer teardown() + utils.Define_users("author_file_test") + // Test Commit + authors := []string{"te"} + message := "Test commit message" + commit := utils.Commit(message, authors) + if commit != "Test commit message\n\nCo-authored-by: TestUser " { + t.Errorf("Commit() = %v; want Test commit message\n", commit) + } +} +// Commit tests END \ No newline at end of file diff --git a/src_code/go_src/cocommit.go b/src_code/go_src/deprecated/cocommit.go similarity index 98% rename from src_code/go_src/cocommit.go rename to src_code/go_src/deprecated/cocommit.go index bf2415a..f0a4c7f 100644 --- a/src_code/go_src/cocommit.go +++ b/src_code/go_src/deprecated/cocommit.go @@ -16,6 +16,7 @@ type user struct { email string names string } +//TODO: Remove later once everything is up and running with the new version // Map of all th users in the author file var users = make(map[string]user) @@ -163,6 +164,8 @@ skip_loop: // commit msg built commit := sb_build() + print(commit) + //NOTE: Uncomment for testing //print(commit) @@ -303,4 +306,4 @@ func check_err(e error) { fmt.Println(e.Error()) os.Exit(2) } -} +} \ No newline at end of file diff --git a/src_code/go_src/cocommit_test.go b/src_code/go_src/deprecated/cocommit_test.go similarity index 100% rename from src_code/go_src/cocommit_test.go rename to src_code/go_src/deprecated/cocommit_test.go diff --git a/src_code/go_src/go.mod b/src_code/go_src/go.mod deleted file mode 100644 index 6abede5..0000000 --- a/src_code/go_src/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/Slug-Boi/cocommit - -go 1.21.9 diff --git a/src_code/go_src/main.go b/src_code/go_src/main.go new file mode 100644 index 0000000..e17f880 --- /dev/null +++ b/src_code/go_src/main.go @@ -0,0 +1,11 @@ +/* +Copyright © 2024 NAME HERE + +*/ +package main + +import "github.com/Slug-Boi/cocommit/src_code/go_src/cmd" + +func main() { + cmd.Execute() +}